feat(agent-browser): add browser automation skill and tool detection (#12858)

*  feat(tool-detectors): add browser automation support and refactor tool detector categories

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

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

* 🔧 chore: add browser automation tool detection UI

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

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

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

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

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

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

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

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

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

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

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

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

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

Made-with: Cursor

*  feat: enhance skill import functionality and testing

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

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

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

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

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

* 🔧 chore: update package.json dependencies

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

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

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

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

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

*  feat: add DevTools toggle to Linux and Windows menus

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

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

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

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

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

* fix: mock binary directory path in tests

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

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

* 🐛 fix: improve authorization notification handling

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

 feat: add desktop onboarding redirect

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

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

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

Made-with: Cursor

* 🔧 chore: update memory limits for build processes

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

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

---------

Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
Innei 2026-03-10 16:13:33 +08:00 committed by GitHub
parent eb7cf10ff9
commit 5e468cd850
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
80 changed files with 1697 additions and 290 deletions

View file

@ -6,3 +6,5 @@ out
*.log*
standalone
release
bin

View file

@ -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',
},

View file

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

View file

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

View file

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

View file

@ -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<PrepareSkillDirectoryResult> {
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<ResolveSkillResourcePathResult> {
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 ====================
/**

View file

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

View file

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

View file

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

View file

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

View file

@ -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',

View file

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

View file

@ -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<ToolStatus>;
detect: () => Promise<ToolStatus>;
/** 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

View file

@ -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' },

View file

@ -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' },

View file

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

View file

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

View file

@ -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', () => {

View file

@ -27,5 +27,6 @@ export const setupElectronApi = () => {
contextBridge.exposeInMainWorld('lobeEnv', {
darwinMajorVersion,
isMacTahoe: process.platform === 'darwin' && darwinMajorVersion >= 25,
platform: process.platform,
});
};

View file

@ -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 - 标准文本搜索工具",

View file

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

View file

@ -0,0 +1,158 @@
/**
* @see https://github.com/vercel-labs/agent-browser/blob/main/skills/agent-browser/SKILL.md
*/
export const systemPrompt = `<agent_browser_guides>
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 <url>\`
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 <url>\`
- \`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 <url1> <url2>\`
## Session and State
- \`agent-browser --session <name> open <url>\`
- \`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.
</agent_browser_guides>
`;

View file

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

View file

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

View file

@ -37,7 +37,7 @@ export const GetAvailableModelsInspector = memo<
<span>{t('builtins.lobe-agent-builder.apiName.getAvailableModels')}</span>
{providerId && (
<>
: <span className={highlightTextStyles.primary}>{providerId}</span>
:<span className={highlightTextStyles.primary}>{providerId}</span>
</>
)}
</div>
@ -47,7 +47,7 @@ export const GetAvailableModelsInspector = memo<
// Loaded state with results
return (
<div className={inspectorTextStyles.root}>
<span>{t('builtins.lobe-agent-builder.apiName.getAvailableModels')}: </span>
<span>{t('builtins.lobe-agent-builder.apiName.getAvailableModels')}:</span>
{modelInfo && (
<span className={highlightTextStyles.primary}>
{modelInfo.displayModels.join(' / ')}

View file

@ -79,7 +79,7 @@ export const UpdateConfigInspector = memo<
<span>{t('builtins.lobe-agent-builder.apiName.updateConfig')}</span>
{displayText && (
<>
: <span className={highlightTextStyles.primary}>{displayText}</span>
:<span className={highlightTextStyles.primary}>{displayText}</span>
</>
)}
{!isLoading && isSuccess && (

View file

@ -39,7 +39,7 @@ export const SearchAgentInspector = memo<
<span>{t('builtins.lobe-group-agent-builder.apiName.searchAgent')}</span>
{query && (
<>
: <span className={highlightTextStyles.primary}>{query}</span>
:<span className={highlightTextStyles.primary}>{query}</span>
</>
)}
{!isLoading &&

View file

@ -72,7 +72,7 @@ export const UpdateGroupInspector = memo<
<span>{t('builtins.lobe-group-agent-builder.apiName.updateGroup')}</span>
{displayText && (
<>
: <span className={highlightTextStyles.primary}>{displayText}</span>
:<span className={highlightTextStyles.primary}>{displayText}</span>
</>
)}
{!isLoading && isSuccess && (

View file

@ -45,7 +45,7 @@ export const AddContextMemoryInspector = memo<
<span>{t('builtins.lobe-user-memory.apiName.addContextMemory')}</span>
{title && (
<>
: <span className={highlightTextStyles.primary}>{title}</span>
:<span className={highlightTextStyles.primary}>{title}</span>
</>
)}
{!isLoading && isSuccess && (

View file

@ -45,7 +45,7 @@ export const AddExperienceMemoryInspector = memo<
<span>{t('builtins.lobe-user-memory.apiName.addExperienceMemory')}</span>
{title && (
<>
: <span className={highlightTextStyles.primary}>{title}</span>
:<span className={highlightTextStyles.primary}>{title}</span>
</>
)}
{!isLoading && isSuccess && (

View file

@ -45,7 +45,7 @@ export const AddIdentityMemoryInspector = memo<
<span>{t('builtins.lobe-user-memory.apiName.addIdentityMemory')}</span>
{title && (
<>
: <span className={highlightTextStyles.primary}>{title}</span>
:<span className={highlightTextStyles.primary}>{title}</span>
</>
)}
{!isLoading && isSuccess && (

View file

@ -45,7 +45,7 @@ export const AddPreferenceMemoryInspector = memo<
<span>{t('builtins.lobe-user-memory.apiName.addPreferenceMemory')}</span>
{title && (
<>
: <span className={highlightTextStyles.primary}>{title}</span>
:<span className={highlightTextStyles.primary}>{title}</span>
</>
)}
{!isLoading && isSuccess && (

View file

@ -45,7 +45,7 @@ export const RemoveIdentityMemoryInspector = memo<
<span>{t('builtins.lobe-user-memory.apiName.removeIdentityMemory')}</span>
{id && (
<>
: <span className={highlightTextStyles.warning}>{id}</span>
:<span className={highlightTextStyles.warning}>{id}</span>
</>
)}
{!isLoading && isSuccess && (

View file

@ -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<ImportFromMarketParams, ImportFromMarketState>
>(({ args, partialArgs, isArgumentsStreaming, isLoading, pluginState }) => {
const { t } = useTranslation('plugin');
const identifier = args?.identifier || partialArgs?.identifier;
const displayName = pluginState?.name || identifier;
if (isArgumentsStreaming && !identifier) {
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skill-store.apiName.importFromMarket')}</span>
</div>
);
}
const isSuccess = pluginState?.success;
const hasResult = pluginState?.success !== undefined;
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>{t('builtins.lobe-skill-store.apiName.importFromMarket')}:</span>
{displayName && <span className={highlightTextStyles.primary}>{displayName}</span>}
{!isLoading &&
hasResult &&
(isSuccess ? (
<Check className={styles.statusIcon} color={cssVar.colorSuccess} size={14} />
) : (
<X className={styles.statusIcon} color={cssVar.colorError} size={14} />
))}
</div>
);
});
ImportFromMarketInspector.displayName = 'ImportFromMarketInspector';

View file

@ -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<SearchSkillParams, SearchSkillState>
>(({ 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 (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skill-store.apiName.searchSkill')}</span>
</div>
);
}
return (
<div
className={cx(
inspectorTextStyles.root,
(isArgumentsStreaming || isLoading) && shinyTextStyles.shinyText,
)}
>
<span>
{t('builtins.lobe-skill-store.apiName.searchSkill')}:{'\u00A0'}
</span>
{query && <span className={highlightTextStyles.primary}>{query}</span>}
{!isLoading &&
!isArgumentsStreaming &&
pluginState &&
(hasResults ? (
<span style={{ marginInlineStart: 4 }}>({total})</span>
) : (
<Text
as={'span'}
color={cssVar.colorTextDescription}
fontSize={12}
style={{ marginInlineStart: 4 }}
>
({t('builtins.lobe-skill-store.inspector.noResults')})
</Text>
))}
</div>
);
});
SearchSkillInspector.displayName = 'SearchSkillInspector';

View file

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

View file

@ -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<SkillItemProps>(({ skill }) => {
const { t } = useTranslation('plugin');
return (
<Flexbox className={styles.item} gap={6}>
<Flexbox gap={2}>
<div className={styles.title}>{skill.name}</div>
<div className={styles.identifier}>{skill.identifier}</div>
</Flexbox>
{(skill.description || skill.summary) && (
<div className={styles.description}>{skill.summary || skill.description}</div>
)}
<Flexbox horizontal gap={6} wrap={'wrap'}>
{skill.version && (
<Tag
size={'small'}
>{`${t('builtins.lobe-skill-store.render.version')}: ${skill.version}`}</Tag>
)}
<Tag
size={'small'}
>{`${t('builtins.lobe-skill-store.render.installs')}: ${skill.installCount}`}</Tag>
{skill.category && <Tag size={'small'}>{skill.category}</Tag>}
</Flexbox>
{skill.repository && (
<Text ellipsis className={styles.meta}>
{`${t('builtins.lobe-skill-store.render.repository')}: ${skill.repository}`}
</Text>
)}
</Flexbox>
);
});
SkillItem.displayName = 'SkillItem';
const SearchSkill = memo<BuiltinRenderProps<SearchSkillParams, SearchSkillState>>(
({ pluginState }) => {
const { t } = useTranslation('plugin');
const items = pluginState?.items || [];
if (items.length === 0) {
return (
<div className={styles.container}>
<div className={styles.empty}>{t('builtins.lobe-skill-store.inspector.noResults')}</div>
</div>
);
}
return (
<Flexbox className={styles.container}>
{items.map((skill) => (
<SkillItem key={skill.identifier} skill={skill} />
))}
</Flexbox>
);
},
);
SearchSkill.displayName = 'SearchSkill';
export default SearchSkill;

View file

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

View file

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

View file

@ -41,7 +41,6 @@ export interface SkillRuntimeService {
options: {
config?: { description?: string; id?: string; name?: string };
description: string;
runInClient?: boolean;
},
) => Promise<CommandResult>;
exportFile?: (path: string, filename: string) => Promise<ExportFileResult>;
@ -67,7 +66,7 @@ export class SkillsExecutionRuntime {
}
async execScript(args: ExecScriptParams): Promise<BuiltinServerRuntimeOutput> {
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,
},

View file

@ -11,11 +11,11 @@ import type { ReadReferenceParams, ReadReferenceState } from '../../../types';
export const ReadReferenceInspector = memo<
BuiltinInspectorProps<ReadReferenceParams, ReadReferenceState>
>(({ 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 (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.readReference')}: </span>
<span>{t('builtins.lobe-skills.apiName.readReference')}:</span>
<span>{path}</span>
</div>
);
@ -35,8 +35,8 @@ export const ReadReferenceInspector = memo<
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>
<span>{t('builtins.lobe-skills.apiName.readReference')}: </span>
<span className={inspectorTextStyles.root}>
<span>{t('builtins.lobe-skills.apiName.readReference')}:</span>
<span className={highlightTextStyles.primary}>{resolvedPath}</span>
</span>
</div>

View file

@ -26,7 +26,7 @@ export const RunSkillInspector = memo<BuiltinInspectorProps<RunSkillParams, RunS
return (
<div className={cx(inspectorTextStyles.root, shinyTextStyles.shinyText)}>
<span>{t('builtins.lobe-skills.apiName.runSkill')}: </span>
<span>{t('builtins.lobe-skills.apiName.runSkill')}:</span>
<span>{name}</span>
</div>
);
@ -35,7 +35,7 @@ export const RunSkillInspector = memo<BuiltinInspectorProps<RunSkillParams, RunS
return (
<div className={cx(inspectorTextStyles.root, isLoading && shinyTextStyles.shinyText)}>
<span>
<span>{t('builtins.lobe-skills.apiName.runSkill')}: </span>
<span>{t('builtins.lobe-skills.apiName.runSkill')}:</span>
<span className={highlightTextStyles.primary}>{activatedName || name}</span>
</span>
</div>

View file

@ -52,10 +52,12 @@ const formatSize = (bytes: number): string => {
const ReadReference = memo<BuiltinRenderProps<ReadReferenceParams, ReadReferenceState>>(
({ 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<BuiltinRenderProps<ReadReferenceParams, ReadReference
<Flexbox className={styles.container} gap={8}>
<Flexbox horizontal align={'center'} justify={'space-between'}>
<Text code ellipsis as={'span'} fontSize={12}>
{path}
{displayPath}
</Text>
{sizeText && (
<Text code as={'span'} fontSize={12} type={'secondary'}>
<Text code noWrap as={'span'} fontSize={12} type={'secondary'}>
{sizeText}
</Text>
)}
@ -88,7 +90,7 @@ const ReadReference = memo<BuiltinRenderProps<ReadReferenceParams, ReadReference
</Markdown>
</Block>
) : (
<Block padding={0} variant={'outlined'}>
<Block padding={8} variant={'outlined'}>
<Highlighter
showLanguage
wrap

View file

@ -2,6 +2,7 @@ export { SkillsManifest } from './manifest';
export { systemPrompt } from './systemRole';
export {
type CommandResult,
type ExecScriptParams,
type ReadReferenceParams,
type RunSkillParams,
SkillsApiName,

View file

@ -0,0 +1,98 @@
import type { LobeChatPluginApi } from '@lobechat/types';
import { SkillsApiName } from './types';
export const runSkillApi: LobeChatPluginApi = {
description:
'Activate a skill by name to load its instructions. Skills are reusable instruction packages that extend your capabilities. Returns the skill content that you should follow to complete the task. If the skill is not found, returns a list of available skills.',
name: SkillsApiName.runSkill,
parameters: {
properties: {
name: {
description: 'The exact name of the skill to activate.',
type: 'string',
},
},
required: ['name'],
type: 'object',
},
};
export const readReferenceApi: LobeChatPluginApi = {
description:
"Read a reference file attached to a skill. Use this to load additional context files mentioned in a skill's content. Requires the id returned by runSkill and the file path.",
name: SkillsApiName.readReference,
parameters: {
properties: {
id: {
description: 'The skill ID or name returned by runSkill.',
type: 'string',
},
path: {
description:
'The virtual path of the reference file to read. Must be a path mentioned in the skill content.',
type: 'string',
},
},
required: ['id', 'path'],
type: 'object',
},
};
export const exportFileApi: LobeChatPluginApi = {
description:
'Export a file generated during skill execution to cloud storage. Use this to save outputs, results, or generated files for the user to download. The file will be uploaded and a permanent download URL will be returned.',
name: SkillsApiName.exportFile,
parameters: {
properties: {
filename: {
description: 'The name for the exported file (e.g., "result.csv", "output.pdf")',
type: 'string',
},
path: {
description:
'The path of the file in the skill execution environment to export (e.g., "./output/result.csv")',
type: 'string',
},
},
required: ['path', 'filename'],
type: 'object',
},
};
export const execScriptBaseParams = {
command: {
description: 'The shell command to execute.',
type: 'string' as const,
},
config: {
description:
'REQUIRED: Current skill context. Must include the id and name from the most recent runSkill call. The server uses this to locate skill resources (e.g., ZIP package for skill files). Example: { "id": "skill_xxx", "name": "skill-name", "description": "..." }',
properties: {
description: {
description: "Current skill's description (optional)",
type: 'string',
},
id: {
description: "Current skill's ID from runSkill state (required for resource lookup)",
type: 'string',
},
name: {
description: "Current skill's name from runSkill state (required for resource lookup)",
type: 'string',
},
},
type: 'object' as const,
},
description: {
description:
'Clear description of what this command does (5-10 words, in active voice). Use the same language as the user input.',
type: 'string' as const,
},
};
export const manifestMeta = {
avatar: '🛠️',
description: 'Activate and use reusable skill packages',
title: 'Skills',
};

View file

@ -0,0 +1,27 @@
import type { BuiltinToolManifest } from '@lobechat/types';
import { execScriptBaseParams, manifestMeta, readReferenceApi, runSkillApi } from './manifest.base';
import { systemPrompt } from './systemRole';
import { SkillsApiName, SkillsIdentifier } from './types';
export const SkillsManifest: BuiltinToolManifest = {
api: [
runSkillApi,
readReferenceApi,
{
description:
"Execute a shell command or script specified in a skill's instructions. Use this when a skill's content instructs you to run CLI commands (e.g., npx, npm, pip). Commands run directly on the local system. IMPORTANT: Always include the 'config' parameter with the current skill's id and name (obtained from runSkill's state) so the system can locate skill resources. Returns the command output.",
humanIntervention: 'required',
name: SkillsApiName.execScript,
parameters: {
properties: execScriptBaseParams,
required: ['description', 'command'],
type: 'object',
},
},
],
identifier: SkillsIdentifier,
meta: manifestMeta,
systemRole: systemPrompt,
type: 'builtin',
};

View file

@ -1,122 +1,34 @@
import type { BuiltinToolManifest } from '@lobechat/types';
import { isDesktop } from './const';
import {
execScriptBaseParams,
exportFileApi,
manifestMeta,
readReferenceApi,
runSkillApi,
} from './manifest.base';
import { systemPrompt } from './systemRole';
import { SkillsApiName, SkillsIdentifier } from './types';
export const SkillsManifest: BuiltinToolManifest = {
api: [
{
description:
'Activate a skill by name to load its instructions. Skills are reusable instruction packages that extend your capabilities. Returns the skill content that you should follow to complete the task. If the skill is not found, returns a list of available skills.',
name: SkillsApiName.runSkill,
parameters: {
properties: {
name: {
description: 'The exact name of the skill to activate.',
type: 'string',
},
},
required: ['name'],
type: 'object',
},
},
{
description:
"Read a reference file attached to a skill. Use this to load additional context files mentioned in a skill's content. Requires the id returned by runSkill and the file path.",
name: SkillsApiName.readReference,
parameters: {
properties: {
id: {
description: 'The skill ID or name returned by runSkill.',
type: 'string',
},
path: {
description:
'The virtual path of the reference file to read. Must be a path mentioned in the skill content.',
type: 'string',
},
},
required: ['id', 'path'],
type: 'object',
},
},
runSkillApi,
readReferenceApi,
{
description:
"Execute a shell command or script specified in a skill's instructions. Use this when a skill's content instructs you to run CLI commands (e.g., npx, npm, pip). IMPORTANT: Always include the 'config' parameter with the current skill's id and name (obtained from runSkill's state) so the system can locate skill resources. Returns the command output.",
humanIntervention: 'required',
name: SkillsApiName.execScript,
parameters: {
properties: {
command: {
description: 'The shell command to execute.',
type: 'string',
},
config: {
description:
'REQUIRED: Current skill context. Must include the id and name from the most recent runSkill call. The server uses this to locate skill resources (e.g., ZIP package for skill files). Example: { "id": "skill_xxx", "name": "skill-name", "description": "..." }',
properties: {
description: {
description: "Current skill's description (optional)",
type: 'string',
},
id: {
description:
"Current skill's ID from runSkill state (required for resource lookup)",
type: 'string',
},
name: {
description:
"Current skill's name from runSkill state (required for resource lookup)",
type: 'string',
},
},
type: 'object',
},
description: {
description:
'Clear description of what this command does (5-10 words, in active voice). Use the same language as the user input.',
type: 'string',
},
...(isDesktop && {
runInClient: {
description:
'Whether to run on the desktop client (for local shell access). MUST be true when command requires local-system tools. Default is false (cloud sandbox execution).',
type: 'boolean',
},
}),
},
properties: execScriptBaseParams,
required: ['description', 'command'],
type: 'object',
},
},
{
description:
'Export a file generated during skill execution to cloud storage. Use this to save outputs, results, or generated files for the user to download. The file will be uploaded and a permanent download URL will be returned.',
name: SkillsApiName.exportFile,
parameters: {
properties: {
filename: {
description: 'The name for the exported file (e.g., "result.csv", "output.pdf")',
type: 'string',
},
path: {
description:
'The path of the file in the skill execution environment to export (e.g., "./output/result.csv")',
type: 'string',
},
},
required: ['path', 'filename'],
type: 'object',
},
},
exportFileApi,
],
identifier: SkillsIdentifier,
meta: {
avatar: '🛠️',
description: 'Activate and use reusable skill packages',
title: 'Skills',
},
meta: manifestMeta,
systemRole: systemPrompt,
type: 'builtin',
};

View file

@ -0,0 +1,44 @@
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.
<core_capabilities>
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)
</core_capabilities>
<workflow>
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
</workflow>
<tool_selection_guidelines>
- **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
</tool_selection_guidelines>
<best_practices>
- 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
</best_practices>
`;

View file

@ -1,27 +1,3 @@
import { isDesktop } from './const';
const runInClientSection = `
<run_in_client>
**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.
</run_in_client>
`;
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.
<core_capabilities>
@ -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
</execscript_vs_runcommand>
${isDesktop ? runInClientSection : ''}
<best_practices>
- Only activate skills when the user's task clearly matches the skill's purpose
- Follow the skill's instructions carefully once loaded

View file

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

View file

@ -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,
];

View file

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

View file

@ -94,6 +94,7 @@ export interface SkillResourceContent {
encoding: 'utf8' | 'base64';
fileHash: string;
fileType: string;
fullPath?: string;
path: string;
size: number;
}

View file

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

View file

@ -121,7 +121,7 @@ const ToolTitle = memo<ToolTitleProps>(
<span className={styles.paramKey}>{' ('}</span>
{params.map(([key, value], index) => (
<span key={key}>
<span className={styles.paramKey}>{key}: </span>
<span className={styles.paramKey}>{key}:</span>
<span className={styles.paramValue}>{formatParamValue(value)}</span>
{index < params.length - 1 && <span className={styles.paramKey}>, </span>}
</span>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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',

View file

@ -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',

View file

@ -52,9 +52,9 @@ const Layout: FC = () => {
{isDesktop && <DesktopAutoOidcOnFirstOpen />}
{isDesktop && <DesktopNavigationBridge />}
{isDesktop && <DesktopFileMenuBridge />}
{isDesktop && <AuthRequiredModal />}
{showCloudPromotion && <CloudBanner />}
</Suspense>
{isDesktop && <AuthRequiredModal />}
<Suspense fallback={null}>{isDesktop && <TitleBar />}</Suspense>
<DndContextWrapper>

View file

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

View file

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

View file

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

View file

@ -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<SkillImportResult> {
async importFromUrl(
input: ImportUrlInput,
options?: { identifier?: string; source?: 'market' | 'user' },
): Promise<SkillImportResult> {
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);

View file

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

View file

@ -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) => ({

View file

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

View file

@ -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<string | undefined> {
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<string | undefined> {
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();

View file

@ -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<PrepareSkillDirectoryResult> {
return ensureElectronIpc().localSystem.handlePrepareSkillDirectory(params);
}
async resolveSkillResourcePath(
params: ResolveSkillResourcePathParams,
): Promise<ResolveSkillResourcePathResult> {
return ensureElectronIpc().localSystem.handleResolveSkillResourcePath(params);
}
async editLocalFile(params: EditLocalFileParams): Promise<EditLocalFileResult> {
return ensureElectronIpc().localSystem.handleEditFile(params);
}

View file

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

View file

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

View file

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

View file

@ -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<string, boolean>;
@ -18,7 +20,7 @@ export interface BuiltinToolState {
}
export const initialBuiltinToolState: BuiltinToolState = {
builtinSkills,
builtinSkills: filterBuiltinSkills(builtinSkills),
builtinToolLoading: {},
builtinTools,
uninstalledBuiltinTools: [],

View file

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

View file

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

View file

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