From 5e468cd850922ce52cfedf0483368b3eddf60bf9 Mon Sep 17 00:00:00 2001 From: Innei Date: Tue, 10 Mar 2026 16:13:33 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(agent-browser):=20add=20browse?= =?UTF-8?q?r=20automation=20skill=20and=20tool=20detection=20(#12858)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ✨ 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 * 🔧 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 * 🔧 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 * 🔧 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 * ✨ 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 * ✨ 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 * 🔧 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 * 🔧 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 * ✨ 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 * ✨ 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 * ✨ 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 * 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 * 🐛 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 * 🐛 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 --------- Signed-off-by: Innei --- apps/desktop/.gitignore | 2 + apps/desktop/electron-builder.mjs | 6 + apps/desktop/package.json | 2 + .../scripts/download-agent-browser.mjs | 105 ++++++++++++ apps/desktop/src/main/const/dir.ts | 7 +- .../src/main/controllers/LocalFileCtr.ts | 133 ++++++++++++--- .../src/main/controllers/ShellCommandCtr.ts | 13 +- .../__tests__/LocalFileCtr.test.ts | 115 ++++++++++++- .../__tests__/ShellCommandCtr.test.ts | 39 ++++- apps/desktop/src/main/core/App.ts | 30 +++- .../src/main/core/__tests__/App.test.ts | 1 + .../BackendProxyProtocolManager.ts | 17 +- .../infrastructure/ToolDetectorManager.ts | 12 +- apps/desktop/src/main/menus/impls/linux.ts | 6 +- apps/desktop/src/main/menus/impls/windows.ts | 2 + .../toolDetectors/agentBrowserDetectors.ts | 13 ++ .../src/main/modules/toolDetectors/index.ts | 1 + apps/desktop/src/preload/electronApi.test.ts | 5 +- apps/desktop/src/preload/electronApi.ts | 1 + locales/zh-CN/setting.json | 3 + package.json | 6 +- .../src/agent-browser/content.ts | 158 ++++++++++++++++++ .../builtin-skills/src/agent-browser/index.ts | 15 ++ packages/builtin-skills/src/index.ts | 5 + .../Inspector/GetAvailableModels/index.tsx | 4 +- .../client/Inspector/UpdateConfig/index.tsx | 2 +- .../client/Inspector/SearchAgent/index.tsx | 2 +- .../client/Inspector/UpdateGroup/index.tsx | 2 +- .../Inspector/AddContextMemory/index.tsx | 2 +- .../Inspector/AddExperienceMemory/index.tsx | 2 +- .../Inspector/AddIdentityMemory/index.tsx | 2 +- .../Inspector/AddPreferenceMemory/index.tsx | 2 +- .../Inspector/RemoveIdentityMemory/index.tsx | 2 +- .../Inspector/ImportFromMarket/index.tsx | 59 +++++++ .../client/Inspector/SearchSkill/index.tsx | 61 +++++++ .../src/client/Inspector/index.ts | 4 + .../src/client/Render/SearchSkill/index.tsx | 120 +++++++++++++ .../src/client/Render/index.ts | 3 + .../src/ExecutionRuntime/index.test.ts | 29 ++++ .../src/ExecutionRuntime/index.ts | 7 +- .../client/Inspector/ReadReference/index.tsx | 10 +- .../src/client/Inspector/RunSkill/index.tsx | 4 +- .../src/client/Render/ReadReference/index.tsx | 10 +- packages/builtin-tool-skills/src/index.ts | 1 + .../builtin-tool-skills/src/manifest.base.ts | 98 +++++++++++ .../src/manifest.desktop.ts | 27 +++ packages/builtin-tool-skills/src/manifest.ts | 112 ++----------- .../src/systemRole.desktop.ts | 44 +++++ .../builtin-tool-skills/src/systemRole.ts | 25 --- packages/builtin-tool-skills/src/types.ts | 7 +- packages/builtin-tools/src/identifiers.ts | 2 + .../src/types/localSystem.ts | 26 +++ packages/types/src/skill/index.ts | 1 + plugins/vite/sharedRendererConfig.ts | 1 + .../Tool/Inspector/ToolTitle.tsx | 2 +- .../Electron/titlebar/TabBar/styles.ts | 2 +- src/helpers/skillFilters.test.ts | 49 ++++++ src/helpers/skillFilters.ts | 48 ++++++ src/helpers/toolFilters.ts | 5 - src/initialize.ts | 5 + .../GlobalProvider/StoreInitialization.tsx | 15 +- src/locales/default/plugin.ts | 4 + src/locales/default/setting.ts | 5 + src/routes/(main)/_layout/index.tsx | 2 +- .../features/ToolDetectorSection.tsx | 5 + .../agentSkills.integration.test.ts | 60 +++++++ src/server/routers/lambda/agentSkills.ts | 5 +- src/server/services/skill/importer.ts | 9 +- .../toolExecution/serverRuntimes/skills.ts | 6 +- src/services/chat/mecha/skillEngineering.ts | 13 +- .../__tests__/desktopSkillRuntime.test.ts | 140 ++++++++++++++++ src/services/electron/desktopSkillRuntime.ts | 69 ++++++++ src/services/electron/localFileService.ts | 16 ++ src/store/tool/builtinToolRegistry.test.ts | 17 ++ .../builtin/executors/lobe-skills.desktop.ts | 47 ++++++ .../slices/builtin/executors/lobe-skills.ts | 31 +--- src/store/tool/slices/builtin/initialState.ts | 4 +- src/store/tool/slices/builtin/selectors.ts | 17 +- src/styles/text.ts | 39 +++-- src/types/global.d.ts | 4 + 80 files changed, 1697 insertions(+), 290 deletions(-) create mode 100644 apps/desktop/scripts/download-agent-browser.mjs create mode 100644 apps/desktop/src/main/modules/toolDetectors/agentBrowserDetectors.ts create mode 100644 packages/builtin-skills/src/agent-browser/content.ts create mode 100644 packages/builtin-skills/src/agent-browser/index.ts create mode 100644 packages/builtin-tool-skill-store/src/client/Inspector/ImportFromMarket/index.tsx create mode 100644 packages/builtin-tool-skill-store/src/client/Inspector/SearchSkill/index.tsx create mode 100644 packages/builtin-tool-skill-store/src/client/Render/SearchSkill/index.tsx create mode 100644 packages/builtin-tool-skills/src/manifest.base.ts create mode 100644 packages/builtin-tool-skills/src/manifest.desktop.ts create mode 100644 packages/builtin-tool-skills/src/systemRole.desktop.ts create mode 100644 src/helpers/skillFilters.test.ts create mode 100644 src/helpers/skillFilters.ts create mode 100644 src/services/electron/__tests__/desktopSkillRuntime.test.ts create mode 100644 src/services/electron/desktopSkillRuntime.ts create mode 100644 src/store/tool/builtinToolRegistry.test.ts create mode 100644 src/store/tool/slices/builtin/executors/lobe-skills.desktop.ts diff --git a/apps/desktop/.gitignore b/apps/desktop/.gitignore index fbef2d26a1..fe69a3276f 100644 --- a/apps/desktop/.gitignore +++ b/apps/desktop/.gitignore @@ -6,3 +6,5 @@ out *.log* standalone release + +bin \ No newline at end of file diff --git a/apps/desktop/electron-builder.mjs b/apps/desktop/electron-builder.mjs index a13e80e740..a5f033f04e 100644 --- a/apps/desktop/electron-builder.mjs +++ b/apps/desktop/electron-builder.mjs @@ -1,3 +1,4 @@ +import { execSync } from 'node:child_process'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -105,6 +106,9 @@ const config = { */ beforePack: async () => { await copyNativeModulesToSource(); + + console.info('📦 Downloading agent-browser binary...'); + execSync('node scripts/download-agent-browser.mjs', { stdio: 'inherit', cwd: __dirname }); }, /** * AfterPack hook for post-processing: @@ -292,6 +296,8 @@ const config = { releaseNotes: process.env.RELEASE_NOTES || undefined, }, + extraResources: [{ from: 'resources/bin', to: 'bin' }], + win: { executableName: 'LobeHub', }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8fb79b7e6e..f2c00e3f2b 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -15,6 +15,7 @@ "build:run-unpack": "electron .", "dev": "electron-vite dev", "dev:static": "cross-env DESKTOP_RENDERER_STATIC=1 npm run dev", + "download:agent-browser": "node scripts/download-agent-browser.mjs", "format": "prettier --write ", "i18n": "tsx scripts/i18nWorkflow/index.ts && lobe-i18n", "postinstall": "electron-builder install-app-deps", @@ -80,6 +81,7 @@ "execa": "^9.6.1", "fast-glob": "^3.3.3", "fetch-socks": "^1.3.2", + "fflate": "^0.8.2", "fix-path": "^5.0.0", "get-port-please": "^3.2.0", "happy-dom": "^20.0.11", diff --git a/apps/desktop/scripts/download-agent-browser.mjs b/apps/desktop/scripts/download-agent-browser.mjs new file mode 100644 index 0000000000..03432a40e4 --- /dev/null +++ b/apps/desktop/scripts/download-agent-browser.mjs @@ -0,0 +1,105 @@ +import fs, { createWriteStream } from 'node:fs'; +import { chmod, mkdir, stat, writeFile } from 'node:fs/promises'; +import https from 'node:https'; +import path from 'node:path'; +import { pipeline } from 'node:stream/promises'; +import { fileURLToPath } from 'node:url'; + +const VERSION = '0.17.0'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const binDir = path.join(__dirname, '..', 'resources', 'bin'); +const versionFile = path.join(binDir, '.agent-browser-version'); + +const platformMap = { darwin: 'darwin', linux: 'linux', win32: 'win32' }; +const archMap = { arm64: 'arm64', x64: 'x64' }; + +const platform = platformMap[process.platform]; +const arch = archMap[process.arch]; + +console.info(`[agent-browser] platform=${process.platform} arch=${process.arch}`); +console.info(`[agent-browser] target: ${platform}-${arch}`); +console.info(`[agent-browser] binDir: ${binDir}`); + +if (!platform || !arch) { + console.error(`[agent-browser] ❌ Unsupported platform: ${process.platform}-${process.arch}`); + process.exit(1); +} + +const isWindows = process.platform === 'win32'; +const binaryName = `agent-browser-${platform}-${arch}${isWindows ? '.exe' : ''}`; +const outputName = `agent-browser${isWindows ? '.exe' : ''}`; +const outputPath = path.join(binDir, outputName); + +// Check if already downloaded +if (fs.existsSync(versionFile)) { + const existing = fs.readFileSync(versionFile, 'utf8').trim(); + console.info(`[agent-browser] existing version: ${existing}, requested: ${VERSION}`); + if (existing === VERSION && fs.existsSync(outputPath)) { + const { size } = await stat(outputPath); + console.info( + `[agent-browser] ✅ v${VERSION} already present (${(size / 1024 / 1024).toFixed(1)} MB), skipping.`, + ); + process.exit(0); + } +} + +const url = `https://github.com/vercel-labs/agent-browser/releases/download/v${VERSION}/${binaryName}`; + +console.info(`[agent-browser] ⬇️ Downloading v${VERSION}...`); +console.info(`[agent-browser] URL: ${url}`); +console.info(`[agent-browser] output: ${outputPath}`); + +await mkdir(binDir, { recursive: true }); + +/** + * Follow redirects and download to a writable stream. + */ +function download(url, dest, maxRedirects = 5) { + return new Promise((resolve, reject) => { + if (maxRedirects <= 0) return reject(new Error('Too many redirects')); + + https + .get(url, { headers: { 'User-Agent': 'lobehub-desktop' } }, (res) => { + if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { + console.info(`[agent-browser] redirect → ${res.headers.location}`); + res.resume(); + return download(res.headers.location, dest, maxRedirects - 1).then(resolve, reject); + } + + if (res.statusCode !== 200) { + res.resume(); + return reject(new Error(`HTTP ${res.statusCode}`)); + } + + const contentLength = res.headers['content-length']; + if (contentLength) { + console.info( + `[agent-browser] content-length: ${(contentLength / 1024 / 1024).toFixed(1)} MB`, + ); + } + + const file = createWriteStream(dest); + pipeline(res, file).then(resolve, reject); + }) + .on('error', reject); + }); +} + +try { + await download(url, outputPath); + + const { size } = await stat(outputPath); + console.info(`[agent-browser] downloaded ${(size / 1024 / 1024).toFixed(1)} MB`); + + if (!isWindows) { + await chmod(outputPath, 0o755); + console.info(`[agent-browser] chmod +x applied`); + } + + await writeFile(versionFile, VERSION, 'utf8'); + console.info(`[agent-browser] ✅ v${VERSION} ready at ${outputPath}`); +} catch (err) { + console.error(`[agent-browser] ❌ Download failed: ${err.message}`); + process.exit(1); +} diff --git a/apps/desktop/src/main/const/dir.ts b/apps/desktop/src/main/const/dir.ts index b312eaacfc..b057d740be 100644 --- a/apps/desktop/src/main/const/dir.ts +++ b/apps/desktop/src/main/const/dir.ts @@ -1,6 +1,7 @@ -import { app } from 'electron'; import { join } from 'node:path'; +import { app } from 'electron'; + export const mainDir = join(__dirname); export const preloadDir = join(mainDir, '../preload'); @@ -9,6 +10,10 @@ export const resourcesDir = join(mainDir, '../../resources'); export const buildDir = join(mainDir, '../../build'); +export const binDir = app.isPackaged + ? join(process.resourcesPath, 'bin') + : join(resourcesDir, 'bin'); + const appPath = app.getAppPath(); export const rendererDir = join(appPath, 'dist', 'renderer'); diff --git a/apps/desktop/src/main/controllers/LocalFileCtr.ts b/apps/desktop/src/main/controllers/LocalFileCtr.ts index dbfc40a329..ca0f3ee922 100644 --- a/apps/desktop/src/main/controllers/LocalFileCtr.ts +++ b/apps/desktop/src/main/controllers/LocalFileCtr.ts @@ -1,6 +1,6 @@ import { constants } from 'node:fs'; -import { access, mkdir, readdir, readFile, rename, stat, writeFile } from 'node:fs/promises'; -import * as path from 'node:path'; +import { access, mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import path from 'node:path'; import { type EditLocalFileParams, @@ -20,7 +20,11 @@ import { type OpenLocalFolderParams, type PickFileParams, type PickFileResult, + type PrepareSkillDirectoryParams, + type PrepareSkillDirectoryResult, type RenameLocalFileResult, + type ResolveSkillResourcePathParams, + type ResolveSkillResourcePathResult, type ShowOpenDialogParams, type ShowOpenDialogResult, type ShowSaveDialogParams, @@ -30,6 +34,7 @@ import { import { loadFile, SYSTEM_FILES_TO_IGNORE } from '@lobechat/file-loaders'; import { createPatch } from 'diff'; import { dialog, shell } from 'electron'; +import { unzipSync } from 'fflate'; import { type FileResult, type SearchOptions } from '@/modules/fileSearch'; import ContentSearchService from '@/services/contentSearchSrv'; @@ -333,29 +338,14 @@ export default class LocalFileCtr extends ControllerModule { // Sort entries based on sortBy and sortOrder results.sort((a, b) => { - let comparison = 0; - - switch (sortBy) { - case 'name': { - comparison = (a.name || '').localeCompare(b.name || ''); - break; - } - case 'modifiedTime': { - comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime(); - break; - } - case 'createdTime': { - comparison = a.createdTime.getTime() - b.createdTime.getTime(); - break; - } - case 'size': { - comparison = a.size - b.size; - break; - } - default: { - comparison = a.modifiedTime.getTime() - b.modifiedTime.getTime(); - } - } + const comparison = + sortBy === 'name' + ? (a.name || '').localeCompare(b.name || '') + : sortBy === 'createdTime' + ? a.createdTime.getTime() - b.createdTime.getTime() + : sortBy === 'size' + ? a.size - b.size + : a.modifiedTime.getTime() - b.modifiedTime.getTime(); return sortOrder === 'desc' ? -comparison : comparison; }); @@ -418,11 +408,12 @@ export default class LocalFileCtr extends ControllerModule { } catch (accessError: any) { if (accessError.code === 'ENOENT') { logger.error(`${logPrefix} Source file does not exist`); - throw new Error(`Source path not found: ${sourcePath}`); + throw new Error(`Source path not found: ${sourcePath}`, { cause: accessError }); } else { logger.error(`${logPrefix} Permission error accessing source file:`, accessError); throw new Error( `Permission denied accessing source path: ${sourcePath}. ${accessError.message}`, + { cause: accessError }, ); } } @@ -609,6 +600,96 @@ export default class LocalFileCtr extends ControllerModule { } } + @IpcMethod() + async handlePrepareSkillDirectory({ + forceRefresh, + url, + zipHash, + }: PrepareSkillDirectoryParams): Promise { + const cacheRoot = path.join(this.app.appStoragePath, 'file-storage', 'skills'); + const extractedDir = path.join(cacheRoot, 'extracted', zipHash); + const markerPath = path.join(extractedDir, '.prepared'); + const zipPath = path.join(cacheRoot, 'archives', `${zipHash}.zip`); + + try { + if (!forceRefresh) { + await access(markerPath, constants.F_OK); + return { extractedDir, success: true, zipPath }; + } + } catch { + // Cache miss, continue preparing the local copy. + } + + try { + const response = await fetch(url); + if (!response.ok) { + throw new Error( + `Failed to download skill package: ${response.status} ${response.statusText}`, + ); + } + + const buffer = Buffer.from(await response.arrayBuffer()); + const extractedFiles = unzipSync(new Uint8Array(buffer)); + + await rm(extractedDir, { force: true, recursive: true }); + await mkdir(path.dirname(zipPath), { recursive: true }); + await mkdir(extractedDir, { recursive: true }); + await writeFile(zipPath, buffer); + + for (const [relativePath, fileContent] of Object.entries(extractedFiles)) { + if (relativePath.endsWith('/')) continue; + + const targetPath = path.resolve(extractedDir, relativePath); + const normalizedRoot = `${path.resolve(extractedDir)}${path.sep}`; + if (targetPath !== path.resolve(extractedDir) && !targetPath.startsWith(normalizedRoot)) { + throw new Error(`Unsafe file path in skill archive: ${relativePath}`); + } + + await mkdir(path.dirname(targetPath), { recursive: true }); + await writeFile(targetPath, Buffer.from(fileContent as Uint8Array)); + } + + await writeFile(markerPath, JSON.stringify({ preparedAt: Date.now(), url, zipHash }), 'utf8'); + + return { extractedDir, success: true, zipPath }; + } catch (error) { + return { + error: (error as Error).message, + extractedDir, + success: false, + zipPath, + }; + } + } + + @IpcMethod() + async handleResolveSkillResourcePath({ + path: resourcePath, + url, + zipHash, + }: ResolveSkillResourcePathParams): Promise { + const prepared = await this.handlePrepareSkillDirectory({ url, zipHash }); + + if (!prepared.success) { + return { error: prepared.error, success: false }; + } + + const normalizedRoot = path.resolve(prepared.extractedDir); + const fullPath = path.resolve(normalizedRoot, resourcePath); + + if (fullPath !== normalizedRoot && !fullPath.startsWith(`${normalizedRoot}${path.sep}`)) { + return { + error: `Unsafe skill resource path: ${resourcePath}`, + success: false, + }; + } + + return { + fullPath, + success: true, + }; + } + // ==================== Search & Find ==================== /** diff --git a/apps/desktop/src/main/controllers/ShellCommandCtr.ts b/apps/desktop/src/main/controllers/ShellCommandCtr.ts index 8b0d7a0226..4db565bf0e 100644 --- a/apps/desktop/src/main/controllers/ShellCommandCtr.ts +++ b/apps/desktop/src/main/controllers/ShellCommandCtr.ts @@ -1,4 +1,8 @@ -import { +import type { ChildProcess } from 'node:child_process'; +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; + +import type { GetCommandOutputParams, GetCommandOutputResult, KillCommandParams, @@ -6,8 +10,6 @@ import { RunCommandParams, RunCommandResult, } from '@lobechat/electron-client-ipc'; -import { ChildProcess, spawn } from 'node:child_process'; -import { randomUUID } from 'node:crypto'; import { createLogger } from '@/utils/logger'; @@ -21,7 +23,7 @@ const MAX_OUTPUT_LENGTH = 80_000; /** * Strip ANSI escape codes from terminal output */ -// eslint-disable-next-line no-control-regex +// eslint-disable-next-line no-control-regex, regexp/no-obscure-range -- ANSI escape sequences use these ranges const ANSI_REGEX = /\u001B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g; const stripAnsi = (str: string): string => str.replaceAll(ANSI_REGEX, ''); @@ -55,6 +57,7 @@ export default class ShellCommandCtr extends ControllerModule { @IpcMethod() async handleRunCommand({ command, + cwd, description, run_in_background, timeout = 120_000, @@ -79,6 +82,7 @@ export default class ShellCommandCtr extends ControllerModule { // Background execution const shellId = randomUUID(); const childProcess = spawn(shellConfig.cmd, shellConfig.args, { + cwd, env: process.env, shell: false, }); @@ -115,6 +119,7 @@ export default class ShellCommandCtr extends ControllerModule { // Synchronous execution with timeout return new Promise((resolve) => { const childProcess = spawn(shellConfig.cmd, shellConfig.args, { + cwd, env: process.env, shell: false, }); diff --git a/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts index ff27296864..0f38c9f68d 100644 --- a/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/LocalFileCtr.test.ts @@ -1,6 +1,7 @@ +import { zipSync } from 'fflate'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { type App } from '@/core/App'; +import { type App } from '@/core/App'; import LocalFileCtr from '../LocalFileCtr'; @@ -8,6 +9,8 @@ const { ipcMainHandleMock } = vi.hoisted(() => ({ ipcMainHandleMock: vi.fn(), })); +const fetchMock = vi.fn(); + // Mock logger vi.mock('@/utils/logger', () => ({ createLogger: () => ({ @@ -34,15 +37,18 @@ vi.mock('electron', () => ({ }, })); +vi.stubGlobal('fetch', fetchMock); + // Mock node:fs/promises and node:fs vi.mock('node:fs/promises', () => ({ - stat: vi.fn(), + access: vi.fn(), + mkdir: vi.fn(), + readFile: vi.fn(), readdir: vi.fn(), rename: vi.fn(), - access: vi.fn(), + rm: vi.fn(), + stat: vi.fn(), writeFile: vi.fn(), - readFile: vi.fn(), - mkdir: vi.fn(), })); vi.mock('node:fs', () => ({ @@ -77,6 +83,7 @@ vi.mock('@/utils/file-system', () => ({ })); const mockApp = { + appStoragePath: '/mock/app/storage', getService: vi.fn((ServiceClass: any) => { // Return different mock based on service class name if (ServiceClass?.name === 'ContentSearchService') { @@ -294,6 +301,104 @@ describe('LocalFileCtr', () => { }); }); + describe('handlePrepareSkillDirectory', () => { + it('should download and extract a skill zip into a local cache directory', async () => { + const zipped = zipSync({ + 'SKILL.md': new TextEncoder().encode('---\nname: Demo\n---\ncontent'), + 'docs/reference.txt': new TextEncoder().encode('hello'), + }); + + fetchMock.mockResolvedValue({ + arrayBuffer: vi + .fn() + .mockResolvedValue( + zipped.buffer.slice(zipped.byteOffset, zipped.byteOffset + zipped.byteLength), + ), + ok: true, + status: 200, + statusText: 'OK', + }); + + vi.mocked(mockFsPromises.access).mockRejectedValue(new Error('missing cache')); + vi.mocked(mockFsPromises.mkdir).mockResolvedValue(undefined); + vi.mocked(mockFsPromises.writeFile).mockResolvedValue(undefined); + + const result = await (localFileCtr as any).handlePrepareSkillDirectory({ + url: 'https://example.com/demo-skill.zip', + zipHash: 'zip-hash-123', + }); + + expect(result).toEqual({ + extractedDir: '/mock/app/storage/file-storage/skills/extracted/zip-hash-123', + success: true, + zipPath: '/mock/app/storage/file-storage/skills/archives/zip-hash-123.zip', + }); + expect(fetchMock).toHaveBeenCalledWith('https://example.com/demo-skill.zip'); + expect(mockFsPromises.writeFile).toHaveBeenCalledWith( + '/mock/app/storage/file-storage/skills/archives/zip-hash-123.zip', + expect.any(Buffer), + ); + expect(mockFsPromises.writeFile).toHaveBeenCalledWith( + '/mock/app/storage/file-storage/skills/extracted/zip-hash-123/SKILL.md', + expect.any(Buffer), + ); + expect(mockFsPromises.writeFile).toHaveBeenCalledWith( + '/mock/app/storage/file-storage/skills/extracted/zip-hash-123/docs/reference.txt', + expect.any(Buffer), + ); + }); + + it('should reuse the cached extracted directory when it is already prepared', async () => { + vi.mocked(mockFsPromises.access).mockResolvedValue(undefined); + + const result = await (localFileCtr as any).handlePrepareSkillDirectory({ + url: 'https://example.com/demo-skill.zip', + zipHash: 'zip-hash-123', + }); + + expect(result).toEqual({ + extractedDir: '/mock/app/storage/file-storage/skills/extracted/zip-hash-123', + success: true, + zipPath: '/mock/app/storage/file-storage/skills/archives/zip-hash-123.zip', + }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(mockFsPromises.writeFile).not.toHaveBeenCalled(); + }); + }); + + describe('handleResolveSkillResourcePath', () => { + it('should resolve a skill resource path from the extracted directory', async () => { + vi.mocked(mockFsPromises.access).mockResolvedValue(undefined); + + const result = await (localFileCtr as any).handleResolveSkillResourcePath({ + path: 'docs/reference.txt', + url: 'https://example.com/demo-skill.zip', + zipHash: 'zip-hash-123', + }); + + expect(result).toEqual({ + fullPath: '/mock/app/storage/file-storage/skills/extracted/zip-hash-123/docs/reference.txt', + success: true, + }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('should reject paths that escape the extracted skill directory', async () => { + vi.mocked(mockFsPromises.access).mockResolvedValue(undefined); + + const result = await (localFileCtr as any).handleResolveSkillResourcePath({ + path: '../secrets.txt', + url: 'https://example.com/demo-skill.zip', + zipHash: 'zip-hash-123', + }); + + expect(result).toEqual({ + error: 'Unsafe skill resource path: ../secrets.txt', + success: false, + }); + }); + }); + describe('handleRenameFile', () => { it('should rename file successfully', async () => { vi.mocked(mockFsPromises.rename).mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts b/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts index 1e057f90ed..e210e6fea5 100644 --- a/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts +++ b/apps/desktop/src/main/controllers/__tests__/ShellCommandCtr.test.ts @@ -214,7 +214,7 @@ describe('ShellCommandCtr', () => { () => stdoutCallback( Buffer.from( - '\x1b[38;5;250m███████╗\x1b[0m\n\x1b[1;32mSuccess\x1b[0m\n\x1b[31mError\x1b[0m', + '\x1B[38;5;250m███████╗\x1B[0m\n\x1B[1;32mSuccess\x1B[0m\n\x1B[31mError\x1B[0m', ), ), 5, @@ -227,7 +227,7 @@ describe('ShellCommandCtr', () => { if (event === 'data') { stderrCallback = callback; setTimeout( - () => stderrCallback(Buffer.from('\x1b[33mwarning:\x1b[0m something happened')), + () => stderrCallback(Buffer.from('\x1B[33mwarning:\x1B[0m something happened')), 5, ); } @@ -241,11 +241,11 @@ describe('ShellCommandCtr', () => { expect(result.success).toBe(true); // ANSI codes should be stripped - expect(result.stdout).not.toContain('\x1b['); + expect(result.stdout).not.toContain('\x1B['); expect(result.stdout).toContain('███████╗'); expect(result.stdout).toContain('Success'); expect(result.stdout).toContain('Error'); - expect(result.stderr).not.toContain('\x1b['); + expect(result.stderr).not.toContain('\x1B['); expect(result.stderr).toContain('warning: something happened'); }); @@ -354,6 +354,37 @@ describe('ShellCommandCtr', () => { Object.defineProperty(process, 'platform', { value: originalPlatform }); }); + + it('should pass cwd to spawn options when provided', async () => { + let exitCallback: (code: number) => void; + + mockChildProcess.on.mockImplementation((event: string, callback: any) => { + if (event === 'exit') { + exitCallback = callback; + setTimeout(() => exitCallback(0), 10); + } + return mockChildProcess; + }); + + mockChildProcess.stdout.on.mockImplementation(() => mockChildProcess.stdout); + mockChildProcess.stderr.on.mockImplementation(() => mockChildProcess.stderr); + + await shellCommandCtr.handleRunCommand({ + command: 'pwd', + cwd: '/tmp/skill-runtime', + description: 'run from cwd', + }); + + expect(mockSpawn).toHaveBeenCalledWith( + '/bin/sh', + ['-c', 'pwd'], + expect.objectContaining({ + cwd: '/tmp/skill-runtime', + env: process.env, + shell: false, + }), + ); + }); }); }); diff --git a/apps/desktop/src/main/core/App.ts b/apps/desktop/src/main/core/App.ts index 53f0d1ef63..2091b6efb5 100644 --- a/apps/desktop/src/main/core/App.ts +++ b/apps/desktop/src/main/core/App.ts @@ -1,22 +1,25 @@ -import { ElectronIPCEventHandler, ElectronIPCServer } from '@lobechat/electron-server-ipc'; -import { app, nativeTheme, protocol } from 'electron'; -import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; -import { macOS, windows } from 'electron-is'; import os from 'node:os'; import { join } from 'node:path'; +import type { ElectronIPCEventHandler } from '@lobechat/electron-server-ipc'; +import { ElectronIPCServer } from '@lobechat/electron-server-ipc'; +import { app, nativeTheme, protocol } from 'electron'; +import installExtension, { REACT_DEVELOPER_TOOLS } from 'electron-devtools-installer'; +import { macOS, windows } from 'electron-is'; + import { name } from '@/../../package.json'; -import { buildDir } from '@/const/dir'; +import { binDir, buildDir } from '@/const/dir'; import { isDev } from '@/const/env'; import { ELECTRON_BE_PROTOCOL_SCHEME } from '@/const/protocol'; -import { IControlModule } from '@/controllers'; +import type { IControlModule } from '@/controllers'; import AuthCtr from '@/controllers/AuthCtr'; import { astSearchDetectors, + browserAutomationDetectors, contentSearchDetectors, fileSearchDetectors, } from '@/modules/toolDetectors'; -import { IServiceModule } from '@/services'; +import type { IServiceModule } from '@/services'; import { createLogger } from '@/utils/logger'; import { BrowserManager } from './browser/BrowserManager'; @@ -79,9 +82,17 @@ export class App { logger.info(` RAM: ${Math.round(os.totalmem() / 1024 / 1024 / 1024)} GB`); logger.info(`PATH: ${app.getAppPath()}`); logger.info(` lng: ${app.getLocale()}`); + logger.info(` bin: ${binDir}`); logger.info('----------------------------------------------'); logger.info('Starting LobeHub...'); + // Append bundled binaries directory to PATH for fallback tool resolution + const pathSep = process.platform === 'win32' ? ';' : ':'; + process.env.PATH = `${process.env.PATH}${pathSep}${binDir}`; + + // Use native mode (pure Rust/CDP) so agent-browser works without Node.js + process.env.AGENT_BROWSER_NATIVE = '1'; + logger.debug('Initializing App'); // Initialize store manager this.storeManager = new StoreManager(this); @@ -191,6 +202,11 @@ export class App { this.toolDetectorManager.register(detector, 'file-search'); } + // Register browser automation tools (agent-browser) + for (const detector of browserAutomationDetectors) { + this.toolDetectorManager.register(detector, 'browser-automation'); + } + logger.info( `Registered ${this.toolDetectorManager.getRegisteredTools().length} tool detectors`, ); diff --git a/apps/desktop/src/main/core/__tests__/App.test.ts b/apps/desktop/src/main/core/__tests__/App.test.ts index d35db93bde..3972e01b10 100644 --- a/apps/desktop/src/main/core/__tests__/App.test.ts +++ b/apps/desktop/src/main/core/__tests__/App.test.ts @@ -90,6 +90,7 @@ vi.mock('@/env', () => ({ })); vi.mock('@/const/dir', () => ({ + binDir: '/mock/bin', buildDir: '/mock/build', rendererDir: '/mock/export/out', appStorageDir: '/mock/storage/path', diff --git a/apps/desktop/src/main/core/infrastructure/BackendProxyProtocolManager.ts b/apps/desktop/src/main/core/infrastructure/BackendProxyProtocolManager.ts index 97d9aa434a..4988103281 100644 --- a/apps/desktop/src/main/core/infrastructure/BackendProxyProtocolManager.ts +++ b/apps/desktop/src/main/core/infrastructure/BackendProxyProtocolManager.ts @@ -42,21 +42,22 @@ export class BackendProxyProtocolManager { private static readonly AUTH_REQUIRED_DEBOUNCE_MS = 1000; private notifyAuthorizationRequired() { - // Debounce: skip if a notification is already scheduled + // Trailing-edge debounce: coalesce rapid 401 bursts and fire AFTER the burst settles. + // This ensures the IPC event is sent after the renderer has had time to mount listeners. if (this.authRequiredDebounceTimer) { - return; + clearTimeout(this.authRequiredDebounceTimer); } this.authRequiredDebounceTimer = setTimeout(() => { this.authRequiredDebounceTimer = null; - }, BackendProxyProtocolManager.AUTH_REQUIRED_DEBOUNCE_MS); - const allWindows = BrowserWindow.getAllWindows(); - for (const win of allWindows) { - if (!win.isDestroyed()) { - win.webContents.send('authorizationRequired'); + const allWindows = BrowserWindow.getAllWindows(); + for (const win of allWindows) { + if (!win.isDestroyed()) { + win.webContents.send('authorizationRequired'); + } } - } + }, BackendProxyProtocolManager.AUTH_REQUIRED_DEBOUNCE_MS); } registerWithRemoteBaseUrl( diff --git a/apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts b/apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts index d0d0a7af51..3f11c655ea 100644 --- a/apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts +++ b/apps/desktop/src/main/core/infrastructure/ToolDetectorManager.ts @@ -1,7 +1,7 @@ import { exec } from 'node:child_process'; import { promisify } from 'node:util'; -import { App } from '@/core/App'; +import type { App } from '@/core/App'; import { createLogger } from '@/utils/logger'; const execPromise = promisify(exec); @@ -25,7 +25,7 @@ export interface IToolDetector { /** Description */ description?: string; /** Detection method */ - detect(): Promise; + detect: () => Promise; /** Tool name, e.g., 'rg', 'mdfind' */ name: string; /** Priority within category, lower number = higher priority */ @@ -35,7 +35,13 @@ export interface IToolDetector { /** * Tool categories */ -export type ToolCategory = 'content-search' | 'ast-search' | 'file-search' | 'system' | 'custom'; +export type ToolCategory = + | 'content-search' + | 'ast-search' + | 'file-search' + | 'browser-automation' + | 'system' + | 'custom'; /** * Tool Detector Manager diff --git a/apps/desktop/src/main/menus/impls/linux.ts b/apps/desktop/src/main/menus/impls/linux.ts index d87313767a..c40cc21589 100644 --- a/apps/desktop/src/main/menus/impls/linux.ts +++ b/apps/desktop/src/main/menus/impls/linux.ts @@ -1,5 +1,5 @@ -/* eslint-disable unicorn/no-array-push-push */ -import { Menu, MenuItemConstructorOptions, app, clipboard, dialog, shell } from 'electron'; +import type { MenuItemConstructorOptions } from 'electron'; +import { app, clipboard, dialog, Menu, shell } from 'electron'; import { isDev } from '@/const/env'; @@ -126,6 +126,8 @@ export class LinuxMenu extends BaseMenuPlatform implements IMenuPlatform { { 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' }, diff --git a/apps/desktop/src/main/menus/impls/windows.ts b/apps/desktop/src/main/menus/impls/windows.ts index d54e5ec30b..fab6764ba3 100644 --- a/apps/desktop/src/main/menus/impls/windows.ts +++ b/apps/desktop/src/main/menus/impls/windows.ts @@ -124,6 +124,8 @@ export class WindowsMenu extends BaseMenuPlatform implements IMenuPlatform { { 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' }, diff --git a/apps/desktop/src/main/modules/toolDetectors/agentBrowserDetectors.ts b/apps/desktop/src/main/modules/toolDetectors/agentBrowserDetectors.ts new file mode 100644 index 0000000000..6e78927436 --- /dev/null +++ b/apps/desktop/src/main/modules/toolDetectors/agentBrowserDetectors.ts @@ -0,0 +1,13 @@ +import type { IToolDetector } from '@/core/infrastructure/ToolDetectorManager'; +import { createCommandDetector } from '@/core/infrastructure/ToolDetectorManager'; + +/** + * agent-browser - Headless browser automation CLI for AI agents + * https://github.com/vercel-labs/agent-browser + */ +export const agentBrowserDetector: IToolDetector = createCommandDetector('agent-browser', { + description: 'Vercel agent-browser - headless browser automation for AI agents', + priority: 1, +}); + +export const browserAutomationDetectors: IToolDetector[] = [agentBrowserDetector]; diff --git a/apps/desktop/src/main/modules/toolDetectors/index.ts b/apps/desktop/src/main/modules/toolDetectors/index.ts index 1081bd535d..09f5ebc63e 100644 --- a/apps/desktop/src/main/modules/toolDetectors/index.ts +++ b/apps/desktop/src/main/modules/toolDetectors/index.ts @@ -5,6 +5,7 @@ * Modules can register additional custom detectors via ToolDetectorManager. */ +export { browserAutomationDetectors } from './agentBrowserDetectors'; export { astSearchDetectors, contentSearchDetectors } from './contentSearchDetectors'; export { fileSearchDetectors } from './fileSearchDetectors'; diff --git a/apps/desktop/src/preload/electronApi.test.ts b/apps/desktop/src/preload/electronApi.test.ts index 5fcf7e06c7..3cfcadba55 100644 --- a/apps/desktop/src/preload/electronApi.test.ts +++ b/apps/desktop/src/preload/electronApi.test.ts @@ -51,7 +51,7 @@ describe('setupElectronApi', () => { }); }); - it('should expose lobeEnv with darwinMajorVersion and isMacTahoe', () => { + it('should expose lobeEnv with darwinMajorVersion, isMacTahoe and platform', () => { setupElectronApi(); const call = mockContextBridgeExposeInMainWorld.mock.calls.find((i) => i[0] === 'lobeEnv'); @@ -66,6 +66,9 @@ describe('setupElectronApi', () => { expect(Object.prototype.hasOwnProperty.call(exposedEnv, 'isMacTahoe')).toBe(true); expect(typeof exposedEnv.isMacTahoe).toBe('boolean'); + + expect(Object.prototype.hasOwnProperty.call(exposedEnv, 'platform')).toBe(true); + expect(['darwin', 'linux', 'win32'].includes(exposedEnv.platform)).toBe(true); }); it('should expose both APIs in correct order', () => { diff --git a/apps/desktop/src/preload/electronApi.ts b/apps/desktop/src/preload/electronApi.ts index dc7692a4ab..b68d839d38 100644 --- a/apps/desktop/src/preload/electronApi.ts +++ b/apps/desktop/src/preload/electronApi.ts @@ -27,5 +27,6 @@ export const setupElectronApi = () => { contextBridge.exposeInMainWorld('lobeEnv', { darwinMajorVersion, isMacTahoe: process.platform === 'darwin' && darwinMajorVersion >= 25, + platform: process.platform, }); }; diff --git a/locales/zh-CN/setting.json b/locales/zh-CN/setting.json index 494667186e..cc61a5d5dc 100644 --- a/locales/zh-CN/setting.json +++ b/locales/zh-CN/setting.json @@ -567,6 +567,8 @@ "settingSystem.oauth.signout.success": "退出登录成功", "settingSystem.title": "系统设置", "settingSystemTools.autoSelectDesc": "系统会自动选择最优的可用工具", + "settingSystemTools.category.browserAutomation": "浏览器自动化", + "settingSystemTools.category.browserAutomation.desc": "用于无头浏览器自动化和网页交互的工具", "settingSystemTools.category.contentSearch": "内容搜索", "settingSystemTools.category.contentSearch.desc": "用于在文件内搜索文本内容的工具", "settingSystemTools.category.fileSearch": "文件搜索", @@ -578,6 +580,7 @@ "settingSystemTools.status.unavailable": "不可用", "settingSystemTools.title": "系统工具", "settingSystemTools.tools.ag.desc": "The Silver Searcher - 快速代码搜索工具", + "settingSystemTools.tools.agentBrowser.desc": "Agent-browser - AI 智能体无头浏览器自动化 CLI", "settingSystemTools.tools.fd.desc": "fd - 快速且用户友好的 find 替代品", "settingSystemTools.tools.find.desc": "Unix find - 标准文件搜索命令", "settingSystemTools.tools.grep.desc": "GNU grep - 标准文本搜索工具", diff --git a/package.json b/package.json index 5eaa75a6f8..88e6d26686 100644 --- a/package.json +++ b/package.json @@ -37,8 +37,8 @@ "build": "bun run build:spa && bun run build:spa:copy && bun run build:next", "build:analyze": "cross-env NODE_OPTIONS=--max-old-space-size=81920 next experimental-analyze", "build:docker": "pnpm run build:spa && pnpm run build:spa:mobile && pnpm run build:spa:copy && cross-env NODE_OPTIONS=--max-old-space-size=8192 DOCKER=true next build && pnpm run build-sitemap", - "build:next": "cross-env NODE_OPTIONS=--max-old-space-size=6144 next build", - "build:spa": "rm -rf public/spa && cross-env NODE_OPTIONS=--max-old-space-size=6144 vite build", + "build:next": "cross-env NODE_OPTIONS=--max-old-space-size=7168 next build", + "build:spa": "rm -rf public/spa && cross-env NODE_OPTIONS=--max-old-space-size=7168 vite build", "build:spa:copy": "tsx scripts/copySpaBuild.mts && tsx scripts/generateSpaTemplates.mts", "build:spa:mobile": "cross-env NODE_OPTIONS=--max-old-space-size=8192 MOBILE=true vite build", "build:vercel": "bun run build && bun run db:migrate", @@ -364,7 +364,7 @@ "react-responsive": "^10.0.1", "react-rnd": "^10.5.2", "react-router-dom": "^7.13.0", - "react-scan": "^0.4.3", + "react-scan": "^0.5.3", "react-virtuoso": "^4.18.1", "react-wrap-balancer": "^1.1.1", "remark": "^15.0.1", diff --git a/packages/builtin-skills/src/agent-browser/content.ts b/packages/builtin-skills/src/agent-browser/content.ts new file mode 100644 index 0000000000..11d960c6fb --- /dev/null +++ b/packages/builtin-skills/src/agent-browser/content.ts @@ -0,0 +1,158 @@ +/** + * @see https://github.com/vercel-labs/agent-browser/blob/main/skills/agent-browser/SKILL.md + */ +export const systemPrompt = ` +You can automate websites and Electron desktop apps with the agent-browser CLI. Use the \`execScript\` tool to run local shell commands. + +# Prerequisites + +The \`agent-browser\` CLI is bundled with the desktop app and runs in native mode (no Node.js required). It automatically detects system Chrome/Chromium. If no browser is found, install Google Chrome. + +# Core Workflow (Snapshot-Ref Pattern) + +Use this 4-step loop for almost all tasks: + +1. Navigate: \`agent-browser open \` +2. Snapshot: \`agent-browser snapshot -i\` (returns refs like \`@e1\`, \`@e2\`) +3. Interact: \`click\`, \`fill\`, \`select\`, etc. with refs +4. Re-snapshot after page changes + +Refs are ephemeral. After navigation, form submit, modal open, or dynamic updates, old refs are invalid. Re-snapshot before the next interaction. + +# Command Chaining + +You can chain commands with \`&&\` in one shell call. The daemon preserves browser state across chained commands. + +\`\`\`bash +agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i +\`\`\` + +Chain only when you do not need to inspect intermediate output. If you must parse snapshot output to discover refs, run snapshot separately. + +# Essential Commands + +## Navigation +- \`agent-browser open \` +- \`agent-browser close\` +- \`agent-browser back\` +- \`agent-browser forward\` +- \`agent-browser reload\` + +## Snapshot and Capture +- \`agent-browser snapshot -i\` (recommended) +- \`agent-browser snapshot -i -C\` (include cursor-interactive elements) +- \`agent-browser screenshot\` +- \`agent-browser screenshot --annotate\` +- \`agent-browser screenshot --full\` +- \`agent-browser pdf output.pdf\` + +## Interaction +- \`agent-browser click @e1\` +- \`agent-browser fill @e2 "text"\` +- \`agent-browser type @e2 "text"\` +- \`agent-browser select @e3 "option"\` +- \`agent-browser check @e4\` +- \`agent-browser press Enter\` +- \`agent-browser scroll down 500\` + +## Retrieval +- \`agent-browser get text @e1\` +- \`agent-browser get url\` +- \`agent-browser get title\` + +## Wait +- \`agent-browser wait @e1\` +- \`agent-browser wait --load networkidle\` +- \`agent-browser wait --url "**/dashboard"\` +- \`agent-browser wait 2000\` + +## Diff and Verification +- \`agent-browser diff snapshot\` +- \`agent-browser diff screenshot --baseline before.png\` +- \`agent-browser diff url \` + +## Session and State +- \`agent-browser --session open \` +- \`agent-browser session list\` +- \`agent-browser state save auth.json\` +- \`agent-browser state load auth.json\` + +## Chrome or Electron Connection + +To control an existing Chrome or Electron app, it must be launched with remote debugging enabled. If the app is already running, quit it first, then relaunch with the flag: + +**macOS (Chrome):** +\`\`\`bash +open -a "Google Chrome" --args --remote-debugging-port=9222 +\`\`\` + +**macOS (Electron app, e.g. Slack):** +\`\`\`bash +open -a "Slack" --args --remote-debugging-port=9222 +\`\`\` + +Then connect and control: +- \`agent-browser --auto-connect snapshot -i\` +- \`agent-browser --cdp 9222 snapshot -i\` +- \`agent-browser connect 9222\` + +# Common Patterns + +## Form Submission +\`\`\`bash +agent-browser open https://example.com/signup +agent-browser snapshot -i +agent-browser fill @e1 "Jane Doe" +agent-browser fill @e2 "jane@example.com" +agent-browser click @e3 +agent-browser wait --load networkidle +agent-browser snapshot -i +\`\`\` + +## Data Extraction +\`\`\`bash +agent-browser open https://example.com/products +agent-browser wait --load networkidle +agent-browser snapshot -i +agent-browser get text @e5 +\`\`\` + +## Annotated Screenshot for Vision Tasks +\`\`\`bash +agent-browser screenshot --annotate +agent-browser click @e2 +\`\`\` + +## Authentication (Auth Vault) +\`\`\`bash +echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin +agent-browser auth login github +\`\`\` + +# Security Controls (Opt-In) + +- Content boundaries: \`AGENT_BROWSER_CONTENT_BOUNDARIES=1\` +- Domain allowlist: \`AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com"\` +- Action policy: \`AGENT_BROWSER_ACTION_POLICY=./policy.json\` +- Output limits: \`AGENT_BROWSER_MAX_OUTPUT=50000\` + +Use allowlists and policies when tasks involve unknown pages or potentially destructive actions. + +# JavaScript Evaluation Notes + +For complex JavaScript, use stdin mode to avoid shell quoting issues: + +\`\`\`bash +agent-browser eval --stdin <<'EVALEOF' +JSON.stringify(Array.from(document.querySelectorAll("a")).map((a) => a.href)) +EVALEOF +\`\`\` + +# Execution Rules in This Runtime + +- Run all agent-browser commands via \`execScript\` with \`runInClient: true\` because it is a local CLI. +- Prefer \`--json\` output when structured parsing is needed. +- Always close sessions when done: \`agent-browser close\` (or named session close). +- If a task stalls, use explicit wait commands instead of blind retries. + +`; diff --git a/packages/builtin-skills/src/agent-browser/index.ts b/packages/builtin-skills/src/agent-browser/index.ts new file mode 100644 index 0000000000..428be1020b --- /dev/null +++ b/packages/builtin-skills/src/agent-browser/index.ts @@ -0,0 +1,15 @@ +import { type BuiltinSkill } from '@lobechat/types'; + +import { systemPrompt } from './content'; + +export const AgentBrowserIdentifier = 'lobe-agent-browser'; + +export const AgentBrowserSkill: BuiltinSkill = { + avatar: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDQ4IDQ4Ij48ZyBmaWxsPSJub25lIj48cGF0aCBmaWxsPSIjOGZiZmZhIiBkPSJNMjQgNDYuNWMtNy40MDEgMC0xMi41OTMtLjI3OC0xNS44NjQtLjU0NGMtMy4yODgtLjI2Ny01LjgyNS0yLjgwNC02LjA5Mi02LjA5MkMxLjc3OCAzNi41OTMgMS41IDMxLjQwMSAxLjUgMjRzLjI3OC0xMi41OTMuNTQ0LTE1Ljg2NGMuMjY3LTMuMjg4IDIuODA0LTUuODI1IDYuMDkyLTYuMDkyQzExLjQwNyAxLjc3OCAxNi41OTkgMS41IDI0IDEuNXMxMi41OTMuMjc4IDE1Ljg2NC41NDRjMy4yODguMjY3IDUuODI1IDIuODA0IDYuMDkyIDYuMDkyYy4yNjYgMy4yNzEuNTQ0IDguNDYzLjU0NCAxNS44NjRzLS4yNzggMTIuNTkzLS41NDQgMTUuODY0Yy0uMjY3IDMuMjg4LTIuODA0IDUuODI1LTYuMDkyIDYuMDkyYy0zLjI3MS4yNjYtOC40NjMuNTQ0LTE1Ljg2NC41NDQiLz48cGF0aCBmaWxsPSIjMjg1OWM1IiBkPSJNNDYuMjYyIDEzSDEuNzM3Yy4wOTItMS45NC4yLTMuNTU2LjMwNy00Ljg2NGMuMjY3LTMuMjg4IDIuODAzLTUuODI1IDYuMDkxLTYuMDkyQzExLjQwNyAxLjc3OCAxNi41OTggMS41IDI0IDEuNWM3LjQwMSAwIDEyLjU5Mi4yNzggMTUuODY0LjU0NGMzLjI4OC4yNjcgNS44MjUgMi44MDQgNi4wOTIgNi4wOTJjLjEwNiAxLjMwOC4yMTQgMi45MjMuMzA2IDQuODY0Ii8+PHBhdGggZmlsbD0iIzhmYmZmYSIgZmlsbC1ydWxlPSJldmVub2RkIiBkPSJNOCA3LjVBMS41IDEuNSAwIDAgMSA5LjUgNmgyYTEuNSAxLjUgMCAwIDEgMCAzaC0yQTEuNSAxLjUgMCAwIDEgOCA3LjVNMTcuNSA2YTEuNSAxLjUgMCAwIDAgMCAzaDJhMS41IDEuNSAwIDAgMCAwLTN6IiBjbGlwLXJ1bGU9ImV2ZW5vZGQiLz48cGF0aCBmaWxsPSIjMjg1OWM1IiBkPSJNMTMuMTIxIDM4LjIzNGMyLjQ4OS0xLjI2NiA1LjExMS0yLjY3OCA3LjI3NS00LjU3MmMuOTY3LS44NDYgMi4xMDQtMi4wNjcgMi4xMDQtMy42NjNzLTEuMTM3LTIuODE3LTIuMTA0LTMuNjYyYy0yLjE2NC0xLjg5NC00Ljc4Ny0zLjMwNy03LjI3NS00LjU3M2EyLjQ5NiAyLjQ5NiAwIDAgMC0zLjM1NyAxLjExN2EyLjUwNSAyLjUwNSAwIDAgMCAxLjExNSAzLjM1MmwuMjQ4LjEyN2MyLjA1NCAxLjA0NSA0LjIxOCAyLjE0NyA1Ljg3NiAzLjY0Yy0xLjY1OCAxLjQ5Mi0zLjgyMSAyLjU5My01Ljg3NSAzLjYzOWwtLjI1LjEyN2EyLjUwNSAyLjUwNSAwIDAgMC0xLjExNCAzLjM1MWEyLjQ5NiAyLjQ5NiAwIDAgMCAzLjM1NyAxLjExN00yNiAzMy41YTIuNSAyLjUgMCAwIDAgMCA1aDEwYTIuNSAyLjUgMCAwIDAgMC01eiIvPjwvZz48L3N2Zz4=`, + content: systemPrompt, + description: + 'Browser automation CLI for AI agents. Use when tasks involve website or Electron interaction such as navigation, form filling, clicking, screenshot capture, scraping data, login flows, and end-to-end app testing.', + identifier: AgentBrowserIdentifier, + name: 'Agent Browser', + source: 'builtin', +}; diff --git a/packages/builtin-skills/src/index.ts b/packages/builtin-skills/src/index.ts index 1ebb27eb9e..c754db0515 100644 --- a/packages/builtin-skills/src/index.ts +++ b/packages/builtin-skills/src/index.ts @@ -1,8 +1,13 @@ import type { BuiltinSkill } from '@lobechat/types'; +import { AgentBrowserSkill } from './agent-browser'; import { ArtifactsSkill } from './artifacts'; +export { AgentBrowserIdentifier } from './agent-browser'; +export { ArtifactsIdentifier } from './artifacts'; + export const builtinSkills: BuiltinSkill[] = [ + AgentBrowserSkill, ArtifactsSkill, // FindSkillsSkill ]; diff --git a/packages/builtin-tool-agent-builder/src/client/Inspector/GetAvailableModels/index.tsx b/packages/builtin-tool-agent-builder/src/client/Inspector/GetAvailableModels/index.tsx index 57abf95a01..6b0c2325db 100644 --- a/packages/builtin-tool-agent-builder/src/client/Inspector/GetAvailableModels/index.tsx +++ b/packages/builtin-tool-agent-builder/src/client/Inspector/GetAvailableModels/index.tsx @@ -37,7 +37,7 @@ export const GetAvailableModelsInspector = memo< {t('builtins.lobe-agent-builder.apiName.getAvailableModels')} {providerId && ( <> - : {providerId} + :{providerId} )} @@ -47,7 +47,7 @@ export const GetAvailableModelsInspector = memo< // Loaded state with results return (
- {t('builtins.lobe-agent-builder.apiName.getAvailableModels')}: + {t('builtins.lobe-agent-builder.apiName.getAvailableModels')}: {modelInfo && ( {modelInfo.displayModels.join(' / ')} diff --git a/packages/builtin-tool-agent-builder/src/client/Inspector/UpdateConfig/index.tsx b/packages/builtin-tool-agent-builder/src/client/Inspector/UpdateConfig/index.tsx index cc5252a4c2..97c4fea504 100644 --- a/packages/builtin-tool-agent-builder/src/client/Inspector/UpdateConfig/index.tsx +++ b/packages/builtin-tool-agent-builder/src/client/Inspector/UpdateConfig/index.tsx @@ -79,7 +79,7 @@ export const UpdateConfigInspector = memo< {t('builtins.lobe-agent-builder.apiName.updateConfig')} {displayText && ( <> - : {displayText} + :{displayText} )} {!isLoading && isSuccess && ( diff --git a/packages/builtin-tool-group-agent-builder/src/client/Inspector/SearchAgent/index.tsx b/packages/builtin-tool-group-agent-builder/src/client/Inspector/SearchAgent/index.tsx index 18b6455246..654cec92f6 100644 --- a/packages/builtin-tool-group-agent-builder/src/client/Inspector/SearchAgent/index.tsx +++ b/packages/builtin-tool-group-agent-builder/src/client/Inspector/SearchAgent/index.tsx @@ -39,7 +39,7 @@ export const SearchAgentInspector = memo< {t('builtins.lobe-group-agent-builder.apiName.searchAgent')} {query && ( <> - : {query} + :{query} )} {!isLoading && diff --git a/packages/builtin-tool-group-agent-builder/src/client/Inspector/UpdateGroup/index.tsx b/packages/builtin-tool-group-agent-builder/src/client/Inspector/UpdateGroup/index.tsx index 1bfc87c60b..cfb142e694 100644 --- a/packages/builtin-tool-group-agent-builder/src/client/Inspector/UpdateGroup/index.tsx +++ b/packages/builtin-tool-group-agent-builder/src/client/Inspector/UpdateGroup/index.tsx @@ -72,7 +72,7 @@ export const UpdateGroupInspector = memo< {t('builtins.lobe-group-agent-builder.apiName.updateGroup')} {displayText && ( <> - : {displayText} + :{displayText} )} {!isLoading && isSuccess && ( diff --git a/packages/builtin-tool-memory/src/client/Inspector/AddContextMemory/index.tsx b/packages/builtin-tool-memory/src/client/Inspector/AddContextMemory/index.tsx index 4541b691c3..dffd0cbdd0 100644 --- a/packages/builtin-tool-memory/src/client/Inspector/AddContextMemory/index.tsx +++ b/packages/builtin-tool-memory/src/client/Inspector/AddContextMemory/index.tsx @@ -45,7 +45,7 @@ export const AddContextMemoryInspector = memo< {t('builtins.lobe-user-memory.apiName.addContextMemory')} {title && ( <> - : {title} + :{title} )} {!isLoading && isSuccess && ( diff --git a/packages/builtin-tool-memory/src/client/Inspector/AddExperienceMemory/index.tsx b/packages/builtin-tool-memory/src/client/Inspector/AddExperienceMemory/index.tsx index 300c07fbef..9c79f08ef6 100644 --- a/packages/builtin-tool-memory/src/client/Inspector/AddExperienceMemory/index.tsx +++ b/packages/builtin-tool-memory/src/client/Inspector/AddExperienceMemory/index.tsx @@ -45,7 +45,7 @@ export const AddExperienceMemoryInspector = memo< {t('builtins.lobe-user-memory.apiName.addExperienceMemory')} {title && ( <> - : {title} + :{title} )} {!isLoading && isSuccess && ( diff --git a/packages/builtin-tool-memory/src/client/Inspector/AddIdentityMemory/index.tsx b/packages/builtin-tool-memory/src/client/Inspector/AddIdentityMemory/index.tsx index ef510f1134..02ae25c2a3 100644 --- a/packages/builtin-tool-memory/src/client/Inspector/AddIdentityMemory/index.tsx +++ b/packages/builtin-tool-memory/src/client/Inspector/AddIdentityMemory/index.tsx @@ -45,7 +45,7 @@ export const AddIdentityMemoryInspector = memo< {t('builtins.lobe-user-memory.apiName.addIdentityMemory')} {title && ( <> - : {title} + :{title} )} {!isLoading && isSuccess && ( diff --git a/packages/builtin-tool-memory/src/client/Inspector/AddPreferenceMemory/index.tsx b/packages/builtin-tool-memory/src/client/Inspector/AddPreferenceMemory/index.tsx index 60b92ce38f..433c55ac16 100644 --- a/packages/builtin-tool-memory/src/client/Inspector/AddPreferenceMemory/index.tsx +++ b/packages/builtin-tool-memory/src/client/Inspector/AddPreferenceMemory/index.tsx @@ -45,7 +45,7 @@ export const AddPreferenceMemoryInspector = memo< {t('builtins.lobe-user-memory.apiName.addPreferenceMemory')} {title && ( <> - : {title} + :{title} )} {!isLoading && isSuccess && ( diff --git a/packages/builtin-tool-memory/src/client/Inspector/RemoveIdentityMemory/index.tsx b/packages/builtin-tool-memory/src/client/Inspector/RemoveIdentityMemory/index.tsx index a0d5ab8eca..8acbcf1104 100644 --- a/packages/builtin-tool-memory/src/client/Inspector/RemoveIdentityMemory/index.tsx +++ b/packages/builtin-tool-memory/src/client/Inspector/RemoveIdentityMemory/index.tsx @@ -45,7 +45,7 @@ export const RemoveIdentityMemoryInspector = memo< {t('builtins.lobe-user-memory.apiName.removeIdentityMemory')} {id && ( <> - : {id} + :{id} )} {!isLoading && isSuccess && ( diff --git a/packages/builtin-tool-skill-store/src/client/Inspector/ImportFromMarket/index.tsx b/packages/builtin-tool-skill-store/src/client/Inspector/ImportFromMarket/index.tsx new file mode 100644 index 0000000000..ce8cc080cf --- /dev/null +++ b/packages/builtin-tool-skill-store/src/client/Inspector/ImportFromMarket/index.tsx @@ -0,0 +1,59 @@ +'use client'; + +import type { BuiltinInspectorProps } from '@lobechat/types'; +import { createStaticStyles, cssVar, cx } from 'antd-style'; +import { Check, X } from 'lucide-react'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles'; + +import type { ImportFromMarketParams, ImportFromMarketState } from '../../../types'; + +const styles = createStaticStyles(({ css }) => ({ + statusIcon: css` + margin-block-end: -2px; + margin-inline-start: 4px; + `, +})); + +export const ImportFromMarketInspector = memo< + BuiltinInspectorProps +>(({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => { + const { t } = useTranslation('plugin'); + + const identifier = args?.identifier || partialArgs?.identifier; + const displayName = pluginState?.name || identifier; + + if (isArgumentsStreaming && !identifier) { + return ( +
+ {t('builtins.lobe-skill-store.apiName.importFromMarket')} +
+ ); + } + + const isSuccess = pluginState?.success; + const hasResult = pluginState?.success !== undefined; + + return ( +
+ {t('builtins.lobe-skill-store.apiName.importFromMarket')}: + {displayName && {displayName}} + {!isLoading && + hasResult && + (isSuccess ? ( + + ) : ( + + ))} +
+ ); +}); + +ImportFromMarketInspector.displayName = 'ImportFromMarketInspector'; diff --git a/packages/builtin-tool-skill-store/src/client/Inspector/SearchSkill/index.tsx b/packages/builtin-tool-skill-store/src/client/Inspector/SearchSkill/index.tsx new file mode 100644 index 0000000000..5e9eb554af --- /dev/null +++ b/packages/builtin-tool-skill-store/src/client/Inspector/SearchSkill/index.tsx @@ -0,0 +1,61 @@ +'use client'; + +import type { BuiltinInspectorProps } from '@lobechat/types'; +import { Text } from '@lobehub/ui'; +import { cssVar, cx } from 'antd-style'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { highlightTextStyles, inspectorTextStyles, shinyTextStyles } from '@/styles'; + +import type { SearchSkillParams, SearchSkillState } from '../../../types'; + +export const SearchSkillInspector = memo< + BuiltinInspectorProps +>(({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => { + const { t } = useTranslation('plugin'); + + const query = args?.q || partialArgs?.q || ''; + const resultCount = pluginState?.items?.length ?? 0; + const total = pluginState?.total ?? resultCount; + const hasResults = resultCount > 0; + + if (isArgumentsStreaming && !query) { + return ( +
+ {t('builtins.lobe-skill-store.apiName.searchSkill')} +
+ ); + } + + return ( +
+ + {t('builtins.lobe-skill-store.apiName.searchSkill')}:{'\u00A0'} + + {query && {query}} + {!isLoading && + !isArgumentsStreaming && + pluginState && + (hasResults ? ( + ({total}) + ) : ( + + ({t('builtins.lobe-skill-store.inspector.noResults')}) + + ))} +
+ ); +}); + +SearchSkillInspector.displayName = 'SearchSkillInspector'; diff --git a/packages/builtin-tool-skill-store/src/client/Inspector/index.ts b/packages/builtin-tool-skill-store/src/client/Inspector/index.ts index 3b4cce1a70..41ba47f6d7 100644 --- a/packages/builtin-tool-skill-store/src/client/Inspector/index.ts +++ b/packages/builtin-tool-skill-store/src/client/Inspector/index.ts @@ -1,6 +1,10 @@ import { SkillStoreApiName } from '../../types'; +import { ImportFromMarketInspector } from './ImportFromMarket'; import { ImportSkillInspector } from './ImportSkill'; +import { SearchSkillInspector } from './SearchSkill'; export const SkillStoreInspectors = { + [SkillStoreApiName.importFromMarket]: ImportFromMarketInspector, [SkillStoreApiName.importSkill]: ImportSkillInspector, + [SkillStoreApiName.searchSkill]: SearchSkillInspector, }; diff --git a/packages/builtin-tool-skill-store/src/client/Render/SearchSkill/index.tsx b/packages/builtin-tool-skill-store/src/client/Render/SearchSkill/index.tsx new file mode 100644 index 0000000000..421c463370 --- /dev/null +++ b/packages/builtin-tool-skill-store/src/client/Render/SearchSkill/index.tsx @@ -0,0 +1,120 @@ +'use client'; + +import type { BuiltinRenderProps } from '@lobechat/types'; +import { Flexbox, Tag, Text } from '@lobehub/ui'; +import { createStaticStyles } from 'antd-style'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { MarketSkillItem, SearchSkillParams, SearchSkillState } from '../../../types'; + +const styles = createStaticStyles(({ css, cssVar }) => ({ + container: css` + overflow: hidden; + + width: 100%; + border: 1px solid ${cssVar.colorBorderSecondary}; + border-radius: 12px; + + background: ${cssVar.colorBgContainer}; + `, + description: css` + font-size: 13px; + line-height: 1.6; + color: ${cssVar.colorTextSecondary}; + `, + empty: css` + padding: 24px; + color: ${cssVar.colorTextTertiary}; + text-align: center; + `, + identifier: css` + font-family: ${cssVar.fontFamilyCode}; + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + `, + item: css` + padding-block: 12px; + padding-inline: 14px; + border-block-end: 1px dashed ${cssVar.colorBorderSecondary}; + + &:last-child { + border-block-end: none; + } + `, + meta: css` + font-size: 12px; + color: ${cssVar.colorTextTertiary}; + `, + title: css` + font-size: 14px; + font-weight: 500; + color: ${cssVar.colorText}; + `, +})); + +interface SkillItemProps { + skill: MarketSkillItem; +} + +const SkillItem = memo(({ skill }) => { + const { t } = useTranslation('plugin'); + + return ( + + +
{skill.name}
+
{skill.identifier}
+
+ {(skill.description || skill.summary) && ( +
{skill.summary || skill.description}
+ )} + + {skill.version && ( + {`${t('builtins.lobe-skill-store.render.version')}: ${skill.version}`} + )} + {`${t('builtins.lobe-skill-store.render.installs')}: ${skill.installCount}`} + {skill.category && {skill.category}} + + {skill.repository && ( + + {`${t('builtins.lobe-skill-store.render.repository')}: ${skill.repository}`} + + )} +
+ ); +}); + +SkillItem.displayName = 'SkillItem'; + +const SearchSkill = memo>( + ({ pluginState }) => { + const { t } = useTranslation('plugin'); + + const items = pluginState?.items || []; + + if (items.length === 0) { + return ( +
+
{t('builtins.lobe-skill-store.inspector.noResults')}
+
+ ); + } + + return ( + + {items.map((skill) => ( + + ))} + + ); + }, +); + +SearchSkill.displayName = 'SearchSkill'; + +export default SearchSkill; diff --git a/packages/builtin-tool-skill-store/src/client/Render/index.ts b/packages/builtin-tool-skill-store/src/client/Render/index.ts index f5f9737691..6c1205360f 100644 --- a/packages/builtin-tool-skill-store/src/client/Render/index.ts +++ b/packages/builtin-tool-skill-store/src/client/Render/index.ts @@ -1,6 +1,9 @@ import { SkillStoreApiName } from '../../types'; import ImportSkill from './ImportSkill'; +import SearchSkill from './SearchSkill'; export const SkillStoreRenders = { + [SkillStoreApiName.importFromMarket]: ImportSkill, [SkillStoreApiName.importSkill]: ImportSkill, + [SkillStoreApiName.searchSkill]: SearchSkill, }; diff --git a/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts b/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts index ea82758319..135b8538b8 100644 --- a/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts +++ b/packages/builtin-tool-skills/src/ExecutionRuntime/index.test.ts @@ -153,4 +153,33 @@ describe('SkillsExecutionRuntime', () => { }); }); }); + + describe('readReference', () => { + it('should expose fullPath in state when provided by the service', async () => { + const service = createMockService({ + findByName: vi.fn().mockResolvedValue({ id: 'skill-1', name: 'demo-skill' }), + readResource: vi.fn().mockResolvedValue({ + content: 'print("hello")', + encoding: 'utf8', + fileHash: 'hash-1', + fileType: 'text/x-python', + fullPath: '/Users/test/lobehub/file-storage/skills/extracted/hash-1/bazi.py', + path: 'bazi.py', + size: 14, + }), + }); + const runtime = new SkillsExecutionRuntime({ service }); + + const result = await runtime.readReference({ id: 'demo-skill', path: 'bazi.py' }); + + expect(result.success).toBe(true); + expect(result.state).toEqual({ + encoding: 'utf8', + fileType: 'text/x-python', + fullPath: '/Users/test/lobehub/file-storage/skills/extracted/hash-1/bazi.py', + path: 'bazi.py', + size: 14, + }); + }); + }); }); diff --git a/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts b/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts index dbc7aced61..acd8acb68d 100644 --- a/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts +++ b/packages/builtin-tool-skills/src/ExecutionRuntime/index.ts @@ -41,7 +41,6 @@ export interface SkillRuntimeService { options: { config?: { description?: string; id?: string; name?: string }; description: string; - runInClient?: boolean; }, ) => Promise; exportFile?: (path: string, filename: string) => Promise; @@ -67,7 +66,7 @@ export class SkillsExecutionRuntime { } async execScript(args: ExecScriptParams): Promise { - const { command, runInClient, description, config } = args; + const { command, description, config } = args; // Try new execScript method first (with cloud sandbox support) if (this.service.execScript) { @@ -75,7 +74,6 @@ export class SkillsExecutionRuntime { const result = await this.service.execScript(command, { config, description, - runInClient, }); const output = [result.output, result.stderr].filter(Boolean).join('\n'); @@ -106,7 +104,7 @@ export class SkillsExecutionRuntime { } try { - const result = await this.service.runCommand({ command, runInClient }); + const result = await this.service.runCommand({ command }); const output = [result.output, result.stderr].filter(Boolean).join('\n'); @@ -193,6 +191,7 @@ export class SkillsExecutionRuntime { state: { encoding: resource.encoding, fileType: resource.fileType, + fullPath: resource.fullPath, path: resource.path, size: resource.size, }, diff --git a/packages/builtin-tool-skills/src/client/Inspector/ReadReference/index.tsx b/packages/builtin-tool-skills/src/client/Inspector/ReadReference/index.tsx index a08feb9577..918511dbac 100644 --- a/packages/builtin-tool-skills/src/client/Inspector/ReadReference/index.tsx +++ b/packages/builtin-tool-skills/src/client/Inspector/ReadReference/index.tsx @@ -11,11 +11,11 @@ import type { ReadReferenceParams, ReadReferenceState } from '../../../types'; export const ReadReferenceInspector = memo< BuiltinInspectorProps ->(({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => { +>(({ args, partialArgs, isArgumentsStreaming, isLoading }) => { const { t } = useTranslation('plugin'); const path = args?.path || partialArgs?.path || ''; - const resolvedPath = pluginState?.path || path; + const resolvedPath = path; if (isArgumentsStreaming) { if (!path) @@ -27,7 +27,7 @@ export const ReadReferenceInspector = memo< return (
- {t('builtins.lobe-skills.apiName.readReference')}: + {t('builtins.lobe-skills.apiName.readReference')}: {path}
); @@ -35,8 +35,8 @@ export const ReadReferenceInspector = memo< return (
- - {t('builtins.lobe-skills.apiName.readReference')}: + + {t('builtins.lobe-skills.apiName.readReference')}: {resolvedPath}
diff --git a/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx b/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx index 3f48cf58ac..722871b0a9 100644 --- a/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx +++ b/packages/builtin-tool-skills/src/client/Inspector/RunSkill/index.tsx @@ -26,7 +26,7 @@ export const RunSkillInspector = memo - {t('builtins.lobe-skills.apiName.runSkill')}: + {t('builtins.lobe-skills.apiName.runSkill')}: {name}
); @@ -35,7 +35,7 @@ export const RunSkillInspector = memo - {t('builtins.lobe-skills.apiName.runSkill')}: + {t('builtins.lobe-skills.apiName.runSkill')}: {activatedName || name} diff --git a/packages/builtin-tool-skills/src/client/Render/ReadReference/index.tsx b/packages/builtin-tool-skills/src/client/Render/ReadReference/index.tsx index d109072531..afc9fcf3b4 100644 --- a/packages/builtin-tool-skills/src/client/Render/ReadReference/index.tsx +++ b/packages/builtin-tool-skills/src/client/Render/ReadReference/index.tsx @@ -52,10 +52,12 @@ const formatSize = (bytes: number): string => { const ReadReference = memo>( ({ content, pluginState }) => { - const { encoding, path, size } = pluginState || {}; + const { encoding, fullPath, path, size } = pluginState || {}; if (!path || !content) return null; + const displayPath = fullPath || path; + const ext = getFileExtension(path); const isMarkdown = ext === 'md' || ext === 'markdown'; const isBinary = encoding === 'base64'; @@ -66,10 +68,10 @@ const ReadReference = memo - {path} + {displayPath} {sizeText && ( - + {sizeText} )} @@ -88,7 +90,7 @@ const ReadReference = memo ) : ( - + +1. Activate a skill by name to load its instructions (runSkill) +2. Read reference files attached to a skill (readReference) +3. Execute shell commands specified in a skill's instructions (execScript) + + + +1. When the user's request matches an available skill, call runSkill with the skill name +2. The skill content will be returned - follow those instructions to complete the task +3. If the skill content references additional files, use readReference to load them +4. If the skill content instructs you to run CLI commands, use execScript to execute them +5. Apply the skill's instructions to fulfill the user's request + + + +- **runSkill**: Call this when the user's task matches one of the available skills + - Provide the exact skill name + - Returns the skill content (instructions, templates, guidelines) that you should follow + - If the skill is not found, you'll receive a list of available skills + +- **readReference**: Call this to read reference files mentioned in a skill's content + - Requires the id (returned by runSkill) and the file path + - Returns the file content for you to use as context + - Only use paths that are referenced in the skill content + +- **execScript**: Call this to execute shell commands mentioned in a skill's content + - **IMPORTANT**: Always provide the \`config\` parameter with the current skill's id and name (from runSkill's state) + - Commands run directly on the local system (OS: {{platform}}) + - Provide the command to execute and a clear description of what it does + - Returns the command output (stdout/stderr) + - Only execute commands that are specified or suggested in the skill content + - Requires user confirmation before execution + + + +- Only activate skills when the user's task clearly matches the skill's purpose +- Follow the skill's instructions carefully once loaded +- Use readReference only for files explicitly mentioned in the skill content +- Use execScript only for commands specified in the skill content, always including config parameter +- If runSkill returns an error with available skills, inform the user what skills are available + +`; diff --git a/packages/builtin-tool-skills/src/systemRole.ts b/packages/builtin-tool-skills/src/systemRole.ts index 393f93bc66..909ed81382 100644 --- a/packages/builtin-tool-skills/src/systemRole.ts +++ b/packages/builtin-tool-skills/src/systemRole.ts @@ -1,27 +1,3 @@ -import { isDesktop } from './const'; - -const runInClientSection = ` - -**IMPORTANT: When to use \`runInClient: true\` for execScript** - -The \`runInClient\` parameter controls WHERE the command executes: -- \`runInClient: false\` (default): Command runs in the **cloud sandbox** - suitable for general CLI tools -- \`runInClient: true\`: Command runs on the **desktop client** - required for local file/shell access - -**MUST set \`runInClient: true\` when the command involves:** -- Accessing local files or directories -- Installing packages globally on the user's machine -- Any operation that requires local system access - -**Keep \`runInClient: false\` (or omit) when:** -- Running general CLI tools (e.g., npx, npm search) -- Command doesn't need local file system access -- Command can run in a sandboxed environment - -**Note:** \`runInClient\` is only available on the **desktop app**. On web platform, commands always run in the cloud sandbox. - -`; - export const systemPrompt = `You have access to a Skills tool that allows you to activate reusable instruction packages (skills) that extend your capabilities. Skills are pre-defined workflows, guidelines, or specialized knowledge that help you handle specific types of tasks. @@ -90,7 +66,6 @@ export const systemPrompt = `You have access to a Skills tool that allows you to 4. If execScript fails, inform user and optionally try runCommand as fallback -${isDesktop ? runInClientSection : ''} - Only activate skills when the user's task clearly matches the skill's purpose - Follow the skill's instructions carefully once loaded diff --git a/packages/builtin-tool-skills/src/types.ts b/packages/builtin-tool-skills/src/types.ts index 56b144c9be..53236a8b94 100644 --- a/packages/builtin-tool-skills/src/types.ts +++ b/packages/builtin-tool-skills/src/types.ts @@ -39,11 +39,6 @@ export interface ExecScriptParams { name?: string; }; description: string; - /** - * Whether to run on the desktop client (for local shell access). - * Only available on desktop. When false or omitted, runs in cloud sandbox. - */ - runInClient?: boolean; } export interface ExecScriptState { @@ -54,7 +49,6 @@ export interface ExecScriptState { export interface RunCommandOptions { command: string; - runInClient?: boolean; timeout?: number; } @@ -73,6 +67,7 @@ export interface ReadReferenceParams { export interface ReadReferenceState { encoding: 'base64' | 'utf8'; fileType: string; + fullPath?: string; path: string; size: number; } diff --git a/packages/builtin-tools/src/identifiers.ts b/packages/builtin-tools/src/identifiers.ts index 001c746ccc..524b355fc2 100644 --- a/packages/builtin-tools/src/identifiers.ts +++ b/packages/builtin-tools/src/identifiers.ts @@ -10,6 +10,7 @@ import { LocalSystemManifest } from '@lobechat/builtin-tool-local-system'; import { MemoryManifest } from '@lobechat/builtin-tool-memory'; import { NotebookManifest } from '@lobechat/builtin-tool-notebook'; import { PageAgentManifest } from '@lobechat/builtin-tool-page-agent'; +import { SkillStoreManifest } from '@lobechat/builtin-tool-skill-store'; import { SkillsManifest } from '@lobechat/builtin-tool-skills'; import { LobeToolsManifest } from '@lobechat/builtin-tool-tools'; import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing'; @@ -30,4 +31,5 @@ export const builtinToolIdentifiers: string[] = [ MemoryManifest.identifier, NotebookManifest.identifier, LobeToolsManifest.identifier, + SkillStoreManifest.identifier, ]; diff --git a/packages/electron-client-ipc/src/types/localSystem.ts b/packages/electron-client-ipc/src/types/localSystem.ts index d155b5a9bf..5d042643fd 100644 --- a/packages/electron-client-ipc/src/types/localSystem.ts +++ b/packages/electron-client-ipc/src/types/localSystem.ts @@ -173,6 +173,7 @@ export interface OpenLocalFolderParams { // Shell command types export interface RunCommandParams { command: string; + cwd?: string; description?: string; run_in_background?: boolean; timeout?: number; @@ -341,3 +342,28 @@ export interface ShowSaveDialogResult { */ filePath?: string; } + +export interface PrepareSkillDirectoryParams { + forceRefresh?: boolean; + url: string; + zipHash: string; +} + +export interface PrepareSkillDirectoryResult { + error?: string; + extractedDir: string; + success: boolean; + zipPath: string; +} + +export interface ResolveSkillResourcePathParams { + path: string; + url: string; + zipHash: string; +} + +export interface ResolveSkillResourcePathResult { + error?: string; + fullPath?: string; + success: boolean; +} diff --git a/packages/types/src/skill/index.ts b/packages/types/src/skill/index.ts index de82b3038e..98abcaf7a7 100644 --- a/packages/types/src/skill/index.ts +++ b/packages/types/src/skill/index.ts @@ -94,6 +94,7 @@ export interface SkillResourceContent { encoding: 'utf8' | 'base64'; fileHash: string; fileType: string; + fullPath?: string; path: string; size: number; } diff --git a/plugins/vite/sharedRendererConfig.ts b/plugins/vite/sharedRendererConfig.ts index 2a294fc128..34724e2fea 100644 --- a/plugins/vite/sharedRendererConfig.ts +++ b/plugins/vite/sharedRendererConfig.ts @@ -146,6 +146,7 @@ export function sharedRendererDefine(options: { isElectron: boolean; isMobile: b return { '__CI__': process.env.CI === 'true' ? 'true' : 'false', + '__DEV__': process.env.NODE_ENV !== 'production' ? 'true' : 'false', '__ELECTRON__': JSON.stringify(options.isElectron), '__MOBILE__': JSON.stringify(options.isMobile), ...nextPublicDefine, diff --git a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx index 70c47a2049..6063a17116 100644 --- a/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx +++ b/src/features/Conversation/Messages/AssistantGroup/Tool/Inspector/ToolTitle.tsx @@ -121,7 +121,7 @@ const ToolTitle = memo( {' ('} {params.map(([key, value], index) => ( - {key}: + {key}: {formatParamValue(value)} {index < params.length - 1 && , } diff --git a/src/features/Electron/titlebar/TabBar/styles.ts b/src/features/Electron/titlebar/TabBar/styles.ts index 98004ccd58..88b39429d7 100644 --- a/src/features/Electron/titlebar/TabBar/styles.ts +++ b/src/features/Electron/titlebar/TabBar/styles.ts @@ -28,7 +28,7 @@ export const useStyles = createStaticStyles(({ css, cssVar }) => ({ width: 180px; padding-block: 2px; - padding-inline: 10px; + padding-inline-start: 10px; border-radius: ${cssVar.borderRadiusSM}; font-size: 12px; diff --git a/src/helpers/skillFilters.test.ts b/src/helpers/skillFilters.test.ts new file mode 100644 index 0000000000..b43ff1b136 --- /dev/null +++ b/src/helpers/skillFilters.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest'; + +import { filterBuiltinSkills, shouldEnableBuiltinSkill } from './skillFilters'; + +describe('skillFilters', () => { + it('should disable agent-browser on web environment', () => { + expect(shouldEnableBuiltinSkill('lobe-agent-browser', { isDesktop: false })).toBe(false); + }); + + it('should enable agent-browser on desktop (non-Windows) environment', () => { + expect( + shouldEnableBuiltinSkill('lobe-agent-browser', { isDesktop: true, isWindows: false }), + ).toBe(true); + }); + + it('should disable agent-browser on desktop Windows', () => { + expect( + shouldEnableBuiltinSkill('lobe-agent-browser', { isDesktop: true, isWindows: true }), + ).toBe(false); + }); + + it('should keep non-desktop-only skills enabled', () => { + expect(shouldEnableBuiltinSkill('lobe-artifacts', { isDesktop: false })).toBe(true); + }); + + it('should filter builtin skills by platform context', () => { + const skills = [ + { + content: 'agent-browser', + description: 'agent-browser', + identifier: 'lobe-agent-browser', + name: 'Agent Browser', + source: 'builtin' as const, + }, + { + content: 'artifacts', + description: 'artifacts', + identifier: 'lobe-artifacts', + name: 'Artifacts', + source: 'builtin' as const, + }, + ]; + + const filtered = filterBuiltinSkills(skills, { isDesktop: false }); + + expect(filtered).toHaveLength(1); + expect(filtered[0].identifier).toBe('lobe-artifacts'); + }); +}); diff --git a/src/helpers/skillFilters.ts b/src/helpers/skillFilters.ts new file mode 100644 index 0000000000..77dfa88e2c --- /dev/null +++ b/src/helpers/skillFilters.ts @@ -0,0 +1,48 @@ +import { AgentBrowserIdentifier } from '@lobechat/builtin-skills'; +import { isDesktop } from '@lobechat/const'; +import { type BuiltinSkill } from '@lobechat/types'; + +export interface BuiltinSkillFilterContext { + isDesktop: boolean; + isWindows?: boolean; +} + +const DESKTOP_ONLY_BUILTIN_SKILLS = new Set([AgentBrowserIdentifier]); + +/** Agent Browser is hidden on Windows (not yet fully supported) */ +const WINDOWS_HIDDEN_BUILTIN_SKILLS = new Set([AgentBrowserIdentifier]); + +const getIsWindows = (): boolean => { + if (typeof process !== 'undefined' && process.platform) { + return process.platform === 'win32'; + } + if (typeof window !== 'undefined' && window.lobeEnv?.platform) { + return window.lobeEnv.platform === 'win32'; + } + return false; +}; + +const DEFAULT_CONTEXT: BuiltinSkillFilterContext = { + isDesktop, + isWindows: getIsWindows(), +}; + +export const shouldEnableBuiltinSkill = ( + skillId: string, + context: BuiltinSkillFilterContext = DEFAULT_CONTEXT, +): boolean => { + if (DESKTOP_ONLY_BUILTIN_SKILLS.has(skillId)) { + if (!context.isDesktop) return false; + if (WINDOWS_HIDDEN_BUILTIN_SKILLS.has(skillId) && context.isWindows) return false; + return true; + } + + return true; +}; + +export const filterBuiltinSkills = ( + skills: BuiltinSkill[], + context: BuiltinSkillFilterContext = DEFAULT_CONTEXT, +): BuiltinSkill[] => { + return skills.filter((skill) => shouldEnableBuiltinSkill(skill.identifier, context)); +}; diff --git a/src/helpers/toolFilters.ts b/src/helpers/toolFilters.ts index 8f3bf60bad..d44af3cf3b 100644 --- a/src/helpers/toolFilters.ts +++ b/src/helpers/toolFilters.ts @@ -16,11 +16,6 @@ export const shouldEnableTool = (toolId: string): boolean => { return isDesktop; } - // Add more platform-specific filters here as needed - // if (toolId === SomeOtherPlatformSpecificTool.identifier) { - // return someCondition; - // } - return true; }; diff --git a/src/initialize.ts b/src/initialize.ts index 291f4dc0c9..3626dd327f 100644 --- a/src/initialize.ts +++ b/src/initialize.ts @@ -4,6 +4,7 @@ import isYesterday from 'dayjs/plugin/isYesterday'; import relativeTime from 'dayjs/plugin/relativeTime'; import utc from 'dayjs/plugin/utc'; import { enableMapSet } from 'immer'; +import { scan } from 'react-scan'; import { isChunkLoadError, notifyChunkError } from '@/utils/chunkError'; @@ -29,3 +30,7 @@ if (typeof window !== 'undefined') { } }); } + +if (__DEV__) { + scan({ enabled: true }); +} diff --git a/src/layout/GlobalProvider/StoreInitialization.tsx b/src/layout/GlobalProvider/StoreInitialization.tsx index 3e82a4d272..29d84cf02c 100644 --- a/src/layout/GlobalProvider/StoreInitialization.tsx +++ b/src/layout/GlobalProvider/StoreInitialization.tsx @@ -1,11 +1,13 @@ 'use client'; import { INBOX_SESSION_ID } from '@lobechat/const'; -import { lazy, memo, Suspense } from 'react'; +import { lazy, memo, Suspense, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { createStoreUpdater } from 'zustand-utils'; +import { isDesktop } from '@/const/version'; import { useIsMobile } from '@/hooks/useIsMobile'; +import { getDesktopOnboardingCompleted } from '@/routes/(desktop)/desktop-onboarding/storage'; import { useAgentStore } from '@/store/agent'; import { useGlobalStore } from '@/store/global'; import { useServerConfigStore } from '@/store/serverConfig'; @@ -65,6 +67,17 @@ const StoreInitialization = memo(() => { const onUserStateSuccess = useUserStateRedirect(); + // Desktop onboarding redirect: must run on mount, independent of API success, + // because the API call itself will 401 when not authenticated. + useEffect(() => { + if (isDesktop && !getDesktopOnboardingCompleted()) { + const { pathname } = window.location; + if (!pathname.startsWith('/desktop-onboarding')) { + window.location.href = '/desktop-onboarding'; + } + } + }, []); + // init user state useInitUserState(isLoginOnInit, serverConfig, { onSuccess: onUserStateSuccess, diff --git a/src/locales/default/plugin.ts b/src/locales/default/plugin.ts index c8a052cccd..0143bfe013 100644 --- a/src/locales/default/plugin.ts +++ b/src/locales/default/plugin.ts @@ -189,6 +189,10 @@ export default { 'builtins.lobe-skill-store.apiName.importFromMarket': 'Import from Market', 'builtins.lobe-skill-store.apiName.importSkill': 'Import Skill', 'builtins.lobe-skill-store.apiName.searchSkill': 'Search Skills', + 'builtins.lobe-skill-store.inspector.noResults': 'No results', + 'builtins.lobe-skill-store.render.installs': 'Installs', + 'builtins.lobe-skill-store.render.repository': 'Repository', + 'builtins.lobe-skill-store.render.version': 'Version', 'builtins.lobe-skill-store.title': 'Skill Store', 'builtins.lobe-skills.apiName.execScript': 'Run Script', 'builtins.lobe-skills.apiName.exportFile': 'Export File', diff --git a/src/locales/default/setting.ts b/src/locales/default/setting.ts index afbee88afc..acdac8d689 100644 --- a/src/locales/default/setting.ts +++ b/src/locales/default/setting.ts @@ -641,6 +641,9 @@ export default { 'settingSystem.oauth.signout.success': 'Sign out successful', 'settingSystem.title': 'System Settings', 'settingSystemTools.autoSelectDesc': 'The best available tool will be automatically selected', + 'settingSystemTools.category.browserAutomation': 'Browser Automation', + 'settingSystemTools.category.browserAutomation.desc': + 'Tools for headless browser automation and web interaction', 'settingSystemTools.category.contentSearch': 'Content Search', 'settingSystemTools.category.contentSearch.desc': 'Tools for searching text content within files', 'settingSystemTools.category.fileSearch': 'File Search', @@ -651,6 +654,8 @@ export default { 'settingSystemTools.status.notDetected': 'Not detected', 'settingSystemTools.status.unavailable': 'Unavailable', 'settingSystemTools.title': 'System Tools', + 'settingSystemTools.tools.agentBrowser.desc': + 'Agent-browser - headless browser automation CLI for AI agents', 'settingSystemTools.tools.ag.desc': 'The Silver Searcher - fast code searching tool', 'settingSystemTools.tools.fd.desc': 'fd - fast and user-friendly alternative to find', 'settingSystemTools.tools.find.desc': 'Unix find - standard file search command', diff --git a/src/routes/(main)/_layout/index.tsx b/src/routes/(main)/_layout/index.tsx index 2f4927607b..675263f18b 100644 --- a/src/routes/(main)/_layout/index.tsx +++ b/src/routes/(main)/_layout/index.tsx @@ -52,9 +52,9 @@ const Layout: FC = () => { {isDesktop && } {isDesktop && } {isDesktop && } - {isDesktop && } {showCloudPromotion && } + {isDesktop && } {isDesktop && } diff --git a/src/routes/(main)/settings/system-tools/features/ToolDetectorSection.tsx b/src/routes/(main)/settings/system-tools/features/ToolDetectorSection.tsx index 71d0cace0e..0c657bc102 100644 --- a/src/routes/(main)/settings/system-tools/features/ToolDetectorSection.tsx +++ b/src/routes/(main)/settings/system-tools/features/ToolDetectorSection.tsx @@ -33,6 +33,11 @@ const TOOL_CATEGORIES = { { descKey: 'settingSystemTools.tools.find.desc', name: 'find' }, ], }, + 'browser-automation': { + descKey: 'settingSystemTools.category.browserAutomation.desc', + titleKey: 'settingSystemTools.category.browserAutomation', + tools: [{ descKey: 'settingSystemTools.tools.agentBrowser.desc', name: 'agent-browser' }], + }, } as const; interface ToolStatusDisplayProps { diff --git a/src/server/routers/lambda/__tests__/integration/agentSkills.integration.test.ts b/src/server/routers/lambda/__tests__/integration/agentSkills.integration.test.ts index a58ae53511..40152c6496 100644 --- a/src/server/routers/lambda/__tests__/integration/agentSkills.integration.test.ts +++ b/src/server/routers/lambda/__tests__/integration/agentSkills.integration.test.ts @@ -16,6 +16,7 @@ vi.mock('@/database/core/db-adaptor', () => ({ // Mock FileService to avoid S3 dependency vi.mock('@/server/services/file', () => ({ FileService: vi.fn().mockImplementation(() => ({ + createGlobalFile: vi.fn().mockResolvedValue({ id: 'mock-global-file-id' }), createFileRecord: vi.fn().mockResolvedValue({ fileId: 'mock-file-id', url: '/f/mock-file-id' }), downloadFileToLocal: vi.fn(), getFileContent: vi.fn(), @@ -69,6 +70,13 @@ vi.mock('@/server/services/skill/parser', () => ({ SkillParser: vi.fn().mockImplementation(() => mockParserInstance), })); +const mockMarketServiceInstance = { + getSkillDownloadUrl: vi.fn(), +}; +vi.mock('@/server/services/market', () => ({ + MarketService: vi.fn().mockImplementation(() => mockMarketServiceInstance), +})); + // Mock global fetch for URL imports const mockFetch = vi.fn(); vi.stubGlobal('fetch', mockFetch); @@ -642,6 +650,58 @@ description: A skill from URL }); }); + describe('importFromMarket', () => { + beforeEach(() => { + mockFetch.mockReset(); + mockMarketServiceInstance.getSkillDownloadUrl.mockReset(); + }); + + it('should keep the market identifier stable when re-importing from market', async () => { + mockMarketServiceInstance.getSkillDownloadUrl + .mockReturnValueOnce('https://market.lobehub.com/api/v1/skills/github.owner.repo/download') + .mockReturnValueOnce( + 'https://market.lobehub.com/api/v1/skills/github.owner.repo/download?version=1.0.0', + ); + + mockFetch.mockResolvedValue({ + arrayBuffer: async () => new ArrayBuffer(8), + headers: { + get: (key: string) => (key === 'content-type' ? 'application/zip' : null), + }, + ok: true, + status: 200, + statusText: 'OK', + }); + + let callCount = 0; + mockParserInstance.parseZipPackage.mockImplementation(() => { + callCount++; + return { + content: callCount === 1 ? '# Original' : '# Updated', + manifest: { + description: callCount === 1 ? 'Original desc' : 'Updated desc', + name: callCount === 1 ? 'Original Name' : 'Updated Name', + }, + resources: new Map(), + zipHash: undefined, + }; + }); + + const caller = agentSkillsRouter.createCaller(createTestContext(userId)); + + const first = await caller.importFromMarket({ identifier: 'github.owner.repo' }); + expect(first!.status).toBe('created'); + expect(first!.skill.identifier).toBe('github.owner.repo'); + + const second = await caller.importFromMarket({ identifier: 'github.owner.repo' }); + expect(second!.status).toBe('updated'); + expect(second!.skill.id).toBe(first!.skill.id); + expect(second!.skill.identifier).toBe('github.owner.repo'); + expect(second!.skill.name).toBe('Updated Name'); + expect(second!.skill.content).toBe('# Updated'); + }); + }); + describe('user isolation', () => { it('should not access skills from other users', async () => { // Create skill for original user diff --git a/src/server/routers/lambda/agentSkills.ts b/src/server/routers/lambda/agentSkills.ts index 916022023f..0c3e476da1 100644 --- a/src/server/routers/lambda/agentSkills.ts +++ b/src/server/routers/lambda/agentSkills.ts @@ -182,7 +182,10 @@ export const agentSkillsRouter = router({ // Get download URL from market service const downloadUrl = ctx.marketService.getSkillDownloadUrl(input.identifier); // Import using the download URL - return await ctx.skillImporter.importFromUrl({ url: downloadUrl }); + return await ctx.skillImporter.importFromUrl( + { url: downloadUrl }, + { identifier: input.identifier, source: 'market' }, + ); } catch (error) { handleSkillImportError(error); } diff --git a/src/server/services/skill/importer.ts b/src/server/services/skill/importer.ts index 4ce8553d6b..8e001ca436 100644 --- a/src/server/services/skill/importer.ts +++ b/src/server/services/skill/importer.ts @@ -280,7 +280,10 @@ export class SkillImporter { * @param input - URL to SKILL.md file * @returns SkillImportResult with status: 'created' | 'updated' | 'unchanged' */ - async importFromUrl(input: ImportUrlInput): Promise { + async importFromUrl( + input: ImportUrlInput, + options?: { identifier?: string; source?: 'market' | 'user' }, + ): Promise { log('importFromUrl: starting with url=%s', input.url); // 1. Validate URL @@ -368,7 +371,7 @@ export class SkillImporter { .replace(/^\//, '') // Remove leading slash .replace(/\.md$/i, '') // Remove .md extension .replaceAll('/', '.'); // Replace slashes with dots - const identifier = `url.${url.host}.${pathPart || 'skill'}`; + const identifier = options?.identifier || `url.${url.host}.${pathPart || 'skill'}`; log('importFromUrl: identifier=%s', identifier); // 5. Check for existing skill @@ -446,7 +449,7 @@ export class SkillImporter { manifest: fullManifest, name: manifest.name, ...(resourceMap && { resources: resourceMap }), - source: 'market', // URL source marked as market + source: options?.source || 'market', // URL source defaults to market ...(zipFileHash && { zipFileHash }), }); log('importFromUrl: created skill id=%s', skill.id); diff --git a/src/server/services/toolExecution/serverRuntimes/skills.ts b/src/server/services/toolExecution/serverRuntimes/skills.ts index ec75cd83bf..d698766f0b 100644 --- a/src/server/services/toolExecution/serverRuntimes/skills.ts +++ b/src/server/services/toolExecution/serverRuntimes/skills.ts @@ -13,6 +13,7 @@ import { sha256 } from 'js-sha256'; import { AgentSkillModel } from '@/database/models/agentSkill'; import { FileModel } from '@/database/models/file'; import { UserModel } from '@/database/models/user'; +import { filterBuiltinSkills } from '@/helpers/skillFilters'; import { FileS3 } from '@/server/modules/S3'; import { FileService } from '@/server/services/file'; import { MarketService } from '@/server/services/market'; @@ -291,7 +292,10 @@ export const skillsRuntime: ServerRuntimeRegistration = { userId: context.userId, }); - return new SkillsExecutionRuntime({ builtinSkills, service }); + return new SkillsExecutionRuntime({ + builtinSkills: filterBuiltinSkills(builtinSkills), + service, + }); }, identifier: SkillsIdentifier, }; diff --git a/src/services/chat/mecha/skillEngineering.ts b/src/services/chat/mecha/skillEngineering.ts index ad410cf4ce..6f3206efd8 100644 --- a/src/services/chat/mecha/skillEngineering.ts +++ b/src/services/chat/mecha/skillEngineering.ts @@ -1,5 +1,6 @@ import { SkillEngine } from '@lobechat/context-engine'; +import { shouldEnableBuiltinSkill } from '@/helpers/skillFilters'; import { getToolStoreState } from '@/store/tool'; /** @@ -13,11 +14,13 @@ export const createSkillEngine = (): SkillEngine => { const toolState = getToolStoreState(); // Source 1: builtin skills - const builtinMetas = (toolState.builtinSkills || []).map((s) => ({ - description: s.description, - identifier: s.identifier, - name: s.name, - })); + const builtinMetas = (toolState.builtinSkills || []) + .filter((s) => shouldEnableBuiltinSkill(s.identifier)) + .map((s) => ({ + description: s.description, + identifier: s.identifier, + name: s.name, + })); // Source 2: DB skills (agentSkills table) const dbMetas = (toolState.agentSkills || []).map((s) => ({ diff --git a/src/services/electron/__tests__/desktopSkillRuntime.test.ts b/src/services/electron/__tests__/desktopSkillRuntime.test.ts new file mode 100644 index 0000000000..91f427f9b7 --- /dev/null +++ b/src/services/electron/__tests__/desktopSkillRuntime.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { desktopSkillRuntimeService } from '@/services/electron/desktopSkillRuntime'; + +const { + getByIdMock, + getByNameMock, + getZipUrlMock, + prepareSkillDirectoryMock, + resolveSkillResourcePathMock, +} = vi.hoisted(() => ({ + getByIdMock: vi.fn(), + getByNameMock: vi.fn(), + getZipUrlMock: vi.fn(), + prepareSkillDirectoryMock: vi.fn(), + resolveSkillResourcePathMock: vi.fn(), +})); + +vi.mock('@/services/skill', () => ({ + agentSkillService: { + getById: getByIdMock, + getByName: getByNameMock, + getZipUrl: getZipUrlMock, + }, +})); + +vi.mock('@/services/electron/localFileService', () => ({ + localFileService: { + prepareSkillDirectory: prepareSkillDirectoryMock, + resolveSkillResourcePath: resolveSkillResourcePathMock, + }, +})); + +describe('desktopSkillRuntimeService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should resolve an extracted directory from a skill name', async () => { + getByNameMock.mockResolvedValue({ + id: 'skill-1', + name: 'demo-skill', + zipFileHash: 'zip-hash-1', + }); + getZipUrlMock.mockResolvedValue({ + name: 'demo-skill', + url: 'https://example.com/demo-skill.zip', + }); + prepareSkillDirectoryMock.mockResolvedValue({ + extractedDir: '/tmp/demo-skill', + success: true, + zipPath: '/tmp/demo-skill.zip', + }); + + const result = await desktopSkillRuntimeService.resolveExecutionDirectory({ + name: 'demo-skill', + }); + + expect(getByNameMock).toHaveBeenCalledWith('demo-skill'); + expect(getZipUrlMock).toHaveBeenCalledWith('skill-1'); + expect(prepareSkillDirectoryMock).toHaveBeenCalledWith({ + url: 'https://example.com/demo-skill.zip', + zipHash: 'zip-hash-1', + }); + expect(result).toBe('/tmp/demo-skill'); + }); + + it('should fall back to skill name when config id is not a persisted skill id', async () => { + getByIdMock.mockResolvedValue(undefined); + getByNameMock.mockResolvedValue({ + id: 'skill-1', + name: 'demo-skill', + zipFileHash: 'zip-hash-1', + }); + getZipUrlMock.mockResolvedValue({ + name: 'demo-skill', + url: 'https://example.com/demo-skill.zip', + }); + prepareSkillDirectoryMock.mockResolvedValue({ + extractedDir: '/tmp/demo-skill', + success: true, + zipPath: '/tmp/demo-skill.zip', + }); + + const result = await desktopSkillRuntimeService.resolveExecutionDirectory({ + id: 'lobe-skills-run-0', + name: 'demo-skill', + }); + + expect(getByIdMock).toHaveBeenCalledWith('lobe-skills-run-0'); + expect(getByNameMock).toHaveBeenCalledWith('demo-skill'); + expect(getZipUrlMock).toHaveBeenCalledWith('skill-1'); + expect(result).toBe('/tmp/demo-skill'); + }); + + it('should return undefined when the skill has no packaged zip', async () => { + getByNameMock.mockResolvedValue({ + id: 'skill-1', + name: 'demo-skill', + zipFileHash: null, + }); + + const result = await desktopSkillRuntimeService.resolveExecutionDirectory({ + name: 'demo-skill', + }); + + expect(getZipUrlMock).not.toHaveBeenCalled(); + expect(prepareSkillDirectoryMock).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('should resolve the full local path for a referenced skill resource', async () => { + getByNameMock.mockResolvedValue({ + id: 'skill-1', + name: 'demo-skill', + zipFileHash: 'zip-hash-1', + }); + getZipUrlMock.mockResolvedValue({ + name: 'demo-skill', + url: 'https://example.com/demo-skill.zip', + }); + resolveSkillResourcePathMock.mockResolvedValue({ + fullPath: '/tmp/demo-skill/docs/bazi.py', + success: true, + zipPath: '/tmp/demo-skill.zip', + }); + + const result = await desktopSkillRuntimeService.resolveReferenceFullPath({ + path: 'docs/bazi.py', + skillName: 'demo-skill', + }); + + expect(resolveSkillResourcePathMock).toHaveBeenCalledWith({ + path: 'docs/bazi.py', + url: 'https://example.com/demo-skill.zip', + zipHash: 'zip-hash-1', + }); + expect(result).toBe('/tmp/demo-skill/docs/bazi.py'); + }); +}); diff --git a/src/services/electron/desktopSkillRuntime.ts b/src/services/electron/desktopSkillRuntime.ts new file mode 100644 index 0000000000..1748bf1a74 --- /dev/null +++ b/src/services/electron/desktopSkillRuntime.ts @@ -0,0 +1,69 @@ +import type { ExecScriptParams } from '@lobechat/builtin-tool-skills'; + +import { agentSkillService } from '@/services/skill'; + +import { localFileService } from './localFileService'; + +type SkillConfig = ExecScriptParams['config']; + +class DesktopSkillRuntimeService { + private async prepareSkillDirectoryForSkill(skill?: { + id: string; + name: string; + zipFileHash?: string | null; + }) { + if (!skill?.zipFileHash) return undefined; + + const zipUrl = await agentSkillService.getZipUrl(skill.id); + if (!zipUrl.url) return undefined; + + const prepared = await localFileService.prepareSkillDirectory({ + url: zipUrl.url, + zipHash: skill.zipFileHash, + }); + + if (!prepared.success) { + throw new Error(prepared.error || `Failed to prepare local skill directory: ${skill.name}`); + } + + return prepared.extractedDir; + } + + private async resolveSkill(params: { id?: string; name?: string }) { + const skillById = params.id ? await agentSkillService.getById(params.id) : undefined; + return skillById ?? (params.name ? await agentSkillService.getByName(params.name) : undefined); + } + + async resolveExecutionDirectory(config?: SkillConfig): Promise { + const skill = await this.resolveSkill({ id: config?.id, name: config?.name }); + return this.prepareSkillDirectoryForSkill(skill); + } + + async resolveReferenceFullPath(params: { + path: string; + skillId?: string; + skillName?: string; + }): Promise { + const skill = await this.resolveSkill({ id: params.skillId, name: params.skillName }); + if (!skill?.zipFileHash) return undefined; + + const zipUrl = await agentSkillService.getZipUrl(skill.id); + if (!zipUrl.url) return undefined; + + const resolved = await localFileService.resolveSkillResourcePath({ + path: params.path, + url: zipUrl.url, + zipHash: skill.zipFileHash, + }); + + if (!resolved.success) { + throw new Error( + resolved.error || `Failed to resolve skill resource path: ${skill.name}/${params.path}`, + ); + } + + return resolved.fullPath; + } +} + +export const desktopSkillRuntimeService = new DesktopSkillRuntimeService(); diff --git a/src/services/electron/localFileService.ts b/src/services/electron/localFileService.ts index 38b4030b71..085cfaa016 100644 --- a/src/services/electron/localFileService.ts +++ b/src/services/electron/localFileService.ts @@ -20,7 +20,11 @@ import { type MoveLocalFilesParams, type OpenLocalFileParams, type OpenLocalFolderParams, + type PrepareSkillDirectoryParams, + type PrepareSkillDirectoryResult, type RenameLocalFileParams, + type ResolveSkillResourcePathParams, + type ResolveSkillResourcePathResult, type RunCommandParams, type RunCommandResult, type ShowSaveDialogParams, @@ -68,6 +72,18 @@ class LocalFileService { return ensureElectronIpc().localSystem.handleWriteFile(params); } + async prepareSkillDirectory( + params: PrepareSkillDirectoryParams, + ): Promise { + return ensureElectronIpc().localSystem.handlePrepareSkillDirectory(params); + } + + async resolveSkillResourcePath( + params: ResolveSkillResourcePathParams, + ): Promise { + return ensureElectronIpc().localSystem.handleResolveSkillResourcePath(params); + } + async editLocalFile(params: EditLocalFileParams): Promise { return ensureElectronIpc().localSystem.handleEditFile(params); } diff --git a/src/store/tool/builtinToolRegistry.test.ts b/src/store/tool/builtinToolRegistry.test.ts new file mode 100644 index 0000000000..a4dd2ca6ba --- /dev/null +++ b/src/store/tool/builtinToolRegistry.test.ts @@ -0,0 +1,17 @@ +import { SkillStoreApiName, SkillStoreIdentifier } from '@lobechat/builtin-tool-skill-store'; +import { SkillStoreInspectors, SkillStoreRenders } from '@lobechat/builtin-tool-skill-store/client'; +import { builtinToolIdentifiers } from '@lobechat/builtin-tools/identifiers'; +import { describe, expect, it } from 'vitest'; + +describe('builtin tool registry', () => { + it('includes skill store in builtin identifiers', () => { + expect(builtinToolIdentifiers).toContain(SkillStoreIdentifier); + }); + + it('registers skill store inspectors and renders for market flows', () => { + expect(SkillStoreInspectors[SkillStoreApiName.importFromMarket]).toBeDefined(); + expect(SkillStoreInspectors[SkillStoreApiName.searchSkill]).toBeDefined(); + expect(SkillStoreRenders[SkillStoreApiName.importFromMarket]).toBeDefined(); + expect(SkillStoreRenders[SkillStoreApiName.searchSkill]).toBeDefined(); + }); +}); diff --git a/src/store/tool/slices/builtin/executors/lobe-skills.desktop.ts b/src/store/tool/slices/builtin/executors/lobe-skills.desktop.ts new file mode 100644 index 0000000000..40f6480e67 --- /dev/null +++ b/src/store/tool/slices/builtin/executors/lobe-skills.desktop.ts @@ -0,0 +1,47 @@ +/** + * Lobe Skills Executor (Desktop) + * + * Desktop version: all commands run locally via localFileService. + * No cloud sandbox, no exportFile. + */ +import { builtinSkills } from '@lobechat/builtin-skills'; +import { SkillsExecutionRuntime } from '@lobechat/builtin-tool-skills/executionRuntime'; +import { SkillsExecutor } from '@lobechat/builtin-tool-skills/executor'; + +import { filterBuiltinSkills } from '@/helpers/skillFilters'; +import { desktopSkillRuntimeService } from '@/services/electron/desktopSkillRuntime'; +import { localFileService } from '@/services/electron/localFileService'; +import { agentSkillService } from '@/services/skill'; + +const runtime = new SkillsExecutionRuntime({ + builtinSkills: filterBuiltinSkills(builtinSkills), + service: { + execScript: async (command, options) => { + const cwd = await desktopSkillRuntimeService.resolveExecutionDirectory(options.config); + const result = await localFileService.runCommand({ command, cwd, timeout: undefined }); + return { + exitCode: result.exit_code ?? 1, + output: result.stdout || result.output || '', + stderr: result.stderr, + success: result.success, + }; + }, + findAll: () => agentSkillService.list(), + findById: (id) => agentSkillService.getById(id), + findByName: (name) => agentSkillService.getByName(name), + readResource: async (id, path) => { + const resource = await agentSkillService.readResource(id, path); + const fullPath = await desktopSkillRuntimeService.resolveReferenceFullPath({ + path, + skillId: id, + }); + + return { + ...resource, + fullPath, + }; + }, + }, +}); + +export const skillsExecutor = new SkillsExecutor(runtime); diff --git a/src/store/tool/slices/builtin/executors/lobe-skills.ts b/src/store/tool/slices/builtin/executors/lobe-skills.ts index 3e9284b49d..14556df487 100644 --- a/src/store/tool/slices/builtin/executors/lobe-skills.ts +++ b/src/store/tool/slices/builtin/executors/lobe-skills.ts @@ -7,30 +7,18 @@ import { builtinSkills } from '@lobechat/builtin-skills'; import { SkillsExecutionRuntime } from '@lobechat/builtin-tool-skills/executionRuntime'; import { SkillsExecutor } from '@lobechat/builtin-tool-skills/executor'; -import { isDesktop } from '@lobechat/const'; +import { filterBuiltinSkills } from '@/helpers/skillFilters'; import { cloudSandboxService } from '@/services/cloudSandbox'; -import { localFileService } from '@/services/electron/localFileService'; import { agentSkillService } from '@/services/skill'; import { useChatStore } from '@/store/chat'; // Create runtime with client-side service const runtime = new SkillsExecutionRuntime({ - builtinSkills, + builtinSkills: filterBuiltinSkills(builtinSkills), service: { execScript: async (command, options) => { - const { runInClient, description, config } = options; - - // Desktop: run in local client if requested - if (isDesktop && runInClient) { - const result = await localFileService.runCommand({ command, timeout: undefined }); - return { - exitCode: result.exit_code ?? 1, - output: result.stdout || result.output || '', - stderr: result.stderr, - success: result.success, - }; - } + const { description, config } = options; // Cloud: execute via Cloud Sandbox with execScript tool // Server will automatically resolve zipUrl based on config.name @@ -105,18 +93,7 @@ const runtime = new SkillsExecutionRuntime({ findById: (id) => agentSkillService.getById(id), findByName: (name) => agentSkillService.getByName(name), readResource: (id, path) => agentSkillService.readResource(id, path), - runCommand: async ({ command, runInClient, timeout }) => { - // Desktop: run in local client if requested - if (isDesktop && runInClient) { - const result = await localFileService.runCommand({ command, timeout }); - return { - exitCode: result.exit_code ?? 1, - output: result.stdout || result.output || '', - stderr: result.stderr, - success: result.success, - }; - } - + runCommand: async ({ command, timeout }) => { // Cloud: execute via Cloud Sandbox // Get current session context for sandbox isolation const chatState = useChatStore.getState(); diff --git a/src/store/tool/slices/builtin/initialState.ts b/src/store/tool/slices/builtin/initialState.ts index 79cf932751..db08171387 100644 --- a/src/store/tool/slices/builtin/initialState.ts +++ b/src/store/tool/slices/builtin/initialState.ts @@ -2,6 +2,8 @@ import { builtinSkills } from '@lobechat/builtin-skills'; import { builtinTools } from '@lobechat/builtin-tools'; import { type BuiltinSkill, type LobeBuiltinTool } from '@lobechat/types'; +import { filterBuiltinSkills } from '@/helpers/skillFilters'; + export interface BuiltinToolState { builtinSkills: BuiltinSkill[]; builtinToolLoading: Record; @@ -18,7 +20,7 @@ export interface BuiltinToolState { } export const initialBuiltinToolState: BuiltinToolState = { - builtinSkills, + builtinSkills: filterBuiltinSkills(builtinSkills), builtinToolLoading: {}, builtinTools, uninstalledBuiltinTools: [], diff --git a/src/store/tool/slices/builtin/selectors.ts b/src/store/tool/slices/builtin/selectors.ts index 944c0307d2..471907c2d3 100644 --- a/src/store/tool/slices/builtin/selectors.ts +++ b/src/store/tool/slices/builtin/selectors.ts @@ -1,5 +1,6 @@ import { type BuiltinSkill, type LobeToolMeta } from '@lobechat/types'; +import { shouldEnableBuiltinSkill } from '@/helpers/skillFilters'; import { shouldEnableTool } from '@/helpers/toolFilters'; import { type ToolStoreState } from '../../initialState'; @@ -41,7 +42,7 @@ const toSkillMeta = (s: BuiltinSkill): LobeToolMeta => ({ const toSkillMetaWithAvailability = (s: BuiltinSkill): LobeToolMetaWithAvailability => ({ ...toSkillMeta(s), - availableInWeb: true, + availableInWeb: shouldEnableBuiltinSkill(s.identifier), }); const getKlavisMetas = (s: ToolStoreState): LobeToolMeta[] => @@ -90,7 +91,12 @@ const metaList = (s: ToolStoreState): LobeToolMeta[] => { .map(toBuiltinMeta); const skillMetas = (s.builtinSkills || []) - .filter((skill) => !uninstalledBuiltinTools.includes(skill.identifier)) + .filter((skill) => { + if (!shouldEnableBuiltinSkill(skill.identifier)) return false; + if (uninstalledBuiltinTools.includes(skill.identifier)) return false; + + return true; + }) .map(toSkillMeta); const agentSkillMetas = agentSkillsSelectors.agentSkillMetaList(s); @@ -151,7 +157,12 @@ const installedAllMetaList = (s: ToolStoreState): LobeToolMetaWithAvailability[] * Get installed builtin skills (excludes uninstalled ones) */ const installedBuiltinSkills = (s: ToolStoreState): BuiltinSkill[] => - (s.builtinSkills || []).filter((skill) => !s.uninstalledBuiltinTools.includes(skill.identifier)); + (s.builtinSkills || []).filter((skill) => { + if (!shouldEnableBuiltinSkill(skill.identifier)) return false; + if (s.uninstalledBuiltinTools.includes(skill.identifier)) return false; + + return true; + }); /** * Get uninstalled builtin tool identifiers diff --git a/src/styles/text.ts b/src/styles/text.ts index db81229710..951cc12bf4 100644 --- a/src/styles/text.ts +++ b/src/styles/text.ts @@ -37,25 +37,24 @@ export const inspectorTextStyles = createStaticStyles(({ css, cssVar }) => ({ * - warning: warning yellow highlight * - gold: gold highlight (for page-agent etc.) */ -export const highlightTextStyles = createStaticStyles(({ css, cssVar }) => ({ - gold: css` +export const highlightTextStyles = createStaticStyles(({ css, cssVar }) => { + const highlightBase = (highlightColor: string) => css` + overflow: hidden; + + min-width: 0; + margin-inline-start: 4px; padding-block-end: 1px; + color: ${cssVar.colorText}; - background: linear-gradient(to top, ${cssVar.gold4} 40%, transparent 40%); - `, - info: css` - padding-block-end: 1px; - color: ${cssVar.colorText}; - background: linear-gradient(to top, ${cssVar.colorInfoBg} 40%, transparent 40%); - `, - primary: css` - padding-block-end: 1px; - color: ${cssVar.colorText}; - background: linear-gradient(to top, ${cssVar.colorPrimaryBgHover} 40%, transparent 40%); - `, - warning: css` - padding-block-end: 1px; - color: ${cssVar.colorText}; - background: linear-gradient(to top, ${cssVar.colorWarningBg} 40%, transparent 40%); - `, -})); + text-overflow: ellipsis; + + background: linear-gradient(to top, ${highlightColor} 40%, transparent 40%); + `; + + return { + gold: highlightBase(cssVar.gold4), + info: highlightBase(cssVar.colorInfoBg), + primary: highlightBase(cssVar.colorPrimaryBgHover), + warning: highlightBase(cssVar.colorWarningBg), + }; +}); diff --git a/src/types/global.d.ts b/src/types/global.d.ts index 2661507e61..26fe5db0ce 100644 --- a/src/types/global.d.ts +++ b/src/types/global.d.ts @@ -25,12 +25,16 @@ declare global { lobeEnv?: { darwinMajorVersion?: number; isMacTahoe?: boolean; + platform?: NodeJS.Platform; }; } /** Vite define: running in CI environment (e.g. CI=true) */ const __CI__: boolean; + /** Vite define: development mode (NODE_ENV !== 'production') */ + const __DEV__: boolean; + /** Vite define: current bundle is mobile variant */ const __MOBILE__: boolean;