mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-06-17 05:12:02 +08:00
Add browser use workspace panel
This commit is contained in:
2966
package-lock.json
generated
2966
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"name": "@cloudcli-ai/cloudcli",
|
||||
"version": "1.34.0",
|
||||
"productName": "CloudCLI",
|
||||
"description": "A web-based UI for Claude Code CLI",
|
||||
"type": "module",
|
||||
"main": "dist-server/server/index.js",
|
||||
@@ -8,6 +9,7 @@
|
||||
"cloudcli": "dist-server/server/cli.js"
|
||||
},
|
||||
"files": [
|
||||
"electron/",
|
||||
"server/",
|
||||
"shared/",
|
||||
"public/api-docs.html",
|
||||
@@ -30,6 +32,10 @@
|
||||
"server:dev": "tsx --tsconfig server/tsconfig.json server/index.js",
|
||||
"server:dev-watch": "tsx watch --tsconfig server/tsconfig.json server/index.js",
|
||||
"client": "vite",
|
||||
"desktop": "electron electron/main.js",
|
||||
"desktop:dev": "ELECTRON_DEV_URL=http://127.0.0.1:5173 electron electron/main.js",
|
||||
"desktop:pack": "npm run build && electron-builder --dir",
|
||||
"desktop:dist:mac": "npm run build && electron-builder --mac dmg zip",
|
||||
"build": "npm run build:client && npm run build:server",
|
||||
"build:client": "vite build",
|
||||
"prebuild:server": "node -e \"require('node:fs').rmSync('dist-server', { recursive: true, force: true })\"",
|
||||
@@ -45,6 +51,53 @@
|
||||
"prepare": "husky",
|
||||
"update:platform": "./update-platform.sh"
|
||||
},
|
||||
"build": {
|
||||
"appId": "ai.cloudcli.desktop",
|
||||
"productName": "CloudCLI",
|
||||
"artifactName": "CloudCLI-${version}-${arch}.${ext}",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"extraMetadata": {
|
||||
"main": "electron/main.js"
|
||||
},
|
||||
"files": [
|
||||
"electron/",
|
||||
"public/",
|
||||
"dist/",
|
||||
"dist-server/",
|
||||
"shared/",
|
||||
"server/",
|
||||
"package.json"
|
||||
],
|
||||
"protocols": [
|
||||
{
|
||||
"name": "CloudCLI",
|
||||
"schemes": [
|
||||
"cloudcli"
|
||||
]
|
||||
}
|
||||
],
|
||||
"mac": {
|
||||
"category": "public.app-category.developer-tools",
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
],
|
||||
"extendInfo": {
|
||||
"CFBundleName": "CloudCLI",
|
||||
"CFBundleDisplayName": "CloudCLI",
|
||||
"CFBundleURLTypes": [
|
||||
{
|
||||
"CFBundleURLName": "CloudCLI",
|
||||
"CFBundleURLSchemes": [
|
||||
"cloudcli"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"keywords": [
|
||||
"claude code",
|
||||
"claude-code",
|
||||
@@ -141,6 +194,8 @@
|
||||
"auto-changelog": "^2.5.0",
|
||||
"autoprefixer": "^10.4.16",
|
||||
"concurrently": "^8.2.2",
|
||||
"electron": "^38.0.0",
|
||||
"electron-builder": "^26.15.3",
|
||||
"eslint": "^9.39.3",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-boundaries": "^6.0.2",
|
||||
|
||||
@@ -72,6 +72,8 @@ import userRoutes from './routes/user.js';
|
||||
import geminiRoutes from './routes/gemini.js';
|
||||
import pluginsRoutes from './routes/plugins.js';
|
||||
import providerRoutes from './modules/providers/provider.routes.js';
|
||||
import browserUseRoutes from './modules/browser-use/browser-use.routes.js';
|
||||
import { browserUseService } from './modules/browser-use/browser-use.service.js';
|
||||
import { startEnabledPluginServers, stopAllPlugins, getPluginPort } from './utils/plugin-process-manager.js';
|
||||
import { initializeDatabase, projectsDb, sessionsDb } from './modules/database/index.js';
|
||||
import { configureWebPush } from './services/vapid-keys.js';
|
||||
@@ -201,6 +203,9 @@ app.use('/api/gemini', authenticateToken, geminiRoutes);
|
||||
// Plugins API Routes (protected)
|
||||
app.use('/api/plugins', authenticateToken, pluginsRoutes);
|
||||
|
||||
// Browser Use API Routes (protected)
|
||||
app.use('/api/browser-use', authenticateToken, browserUseRoutes);
|
||||
|
||||
// Unified provider MCP routes (protected)
|
||||
app.use('/api/providers', authenticateToken, providerRoutes);
|
||||
|
||||
@@ -1694,12 +1699,13 @@ async function startServer() {
|
||||
|
||||
await closeSessionsWatcher();
|
||||
// Clean up plugin processes on shutdown
|
||||
const shutdownPlugins = async () => {
|
||||
const shutdownRuntimeServices = async () => {
|
||||
await browserUseService.stopAllSessions();
|
||||
await stopAllPlugins();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', () => void shutdownPlugins());
|
||||
process.on('SIGINT', () => void shutdownPlugins());
|
||||
process.on('SIGTERM', () => void shutdownRuntimeServices());
|
||||
process.on('SIGINT', () => void shutdownRuntimeServices());
|
||||
} catch (error) {
|
||||
console.error('[ERROR] Failed to start server:', error);
|
||||
process.exit(1);
|
||||
|
||||
76
server/modules/browser-use/browser-use.routes.ts
Normal file
76
server/modules/browser-use/browser-use.routes.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import express from 'express';
|
||||
|
||||
import { browserUseService } from '@/modules/browser-use/browser-use.service.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
type AuthenticatedRequest = express.Request & {
|
||||
user?: {
|
||||
id?: string | number;
|
||||
};
|
||||
};
|
||||
|
||||
function requireUser(req: AuthenticatedRequest): { id: string | number } {
|
||||
const userId = req.user?.id;
|
||||
if (userId === undefined || userId === null) {
|
||||
throw new Error('Authenticated user is required.');
|
||||
}
|
||||
return { id: userId };
|
||||
}
|
||||
|
||||
function readParam(value: string | string[] | undefined): string {
|
||||
return Array.isArray(value) ? value[0] || '' : value || '';
|
||||
}
|
||||
|
||||
router.get('/status', (_req, res) => {
|
||||
res.json({ success: true, data: browserUseService.getStatus() });
|
||||
});
|
||||
|
||||
router.get('/sessions', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
res.json({ success: true, data: { sessions: await browserUseService.listSessions(requireUser(req)) } });
|
||||
} catch (error) {
|
||||
res.status(401).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to list browser sessions.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sessions', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const session = await browserUseService.createSession(requireUser(req));
|
||||
res.status(session.status === 'unavailable' ? 202 : 201).json({ success: true, data: { session } });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to create browser session.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sessions/:sessionId/navigate', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const session = await browserUseService.navigate(requireUser(req), readParam(req.params.sessionId), String(req.body?.url || ''));
|
||||
res.json({ success: true, data: { session } });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to navigate browser session.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/sessions/:sessionId/stop', async (req: AuthenticatedRequest, res) => {
|
||||
try {
|
||||
const result = await browserUseService.stopSession(requireUser(req), readParam(req.params.sessionId));
|
||||
res.json({ success: true, data: result });
|
||||
} catch (error) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to stop browser session.',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
345
server/modules/browser-use/browser-use.service.ts
Normal file
345
server/modules/browser-use/browser-use.service.ts
Normal file
@@ -0,0 +1,345 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import dns from 'node:dns/promises';
|
||||
import net from 'node:net';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
|
||||
const MAX_SESSIONS_PER_OWNER = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_MAX_SESSIONS_PER_OWNER || '3', 10);
|
||||
const SESSION_TTL_MS = Number.parseInt(process.env.CLOUDCLI_BROWSER_USE_SESSION_TTL_MS || String(30 * 60 * 1000), 10);
|
||||
const ALLOW_PRIVATE_NETWORKS = process.env.CLOUDCLI_BROWSER_USE_ALLOW_PRIVATE_NETWORKS === '1';
|
||||
|
||||
type BrowserUseRuntime = 'cloud' | 'local';
|
||||
type BrowserUseSessionStatus = 'ready' | 'stopped' | 'unavailable';
|
||||
|
||||
type BrowserUseSession = {
|
||||
id: string;
|
||||
ownerId: string;
|
||||
runtime: BrowserUseRuntime;
|
||||
status: BrowserUseSessionStatus;
|
||||
url: string | null;
|
||||
title: string | null;
|
||||
screenshotDataUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastAction: string | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
type PublicBrowserUseSession = Omit<BrowserUseSession, 'ownerId'>;
|
||||
|
||||
type RuntimeHandle = {
|
||||
browser?: any;
|
||||
page?: any;
|
||||
};
|
||||
|
||||
type BrowserUseOwner = {
|
||||
id: string | number;
|
||||
};
|
||||
|
||||
const sessions = new Map<string, BrowserUseSession>();
|
||||
const handles = new Map<string, RuntimeHandle>();
|
||||
|
||||
function getRuntime(): BrowserUseRuntime {
|
||||
return IS_PLATFORM ? 'cloud' : 'local';
|
||||
}
|
||||
|
||||
function isBrowserUseEnabled(): boolean {
|
||||
return process.env.CLOUDCLI_BROWSER_USE_ENABLED === '1';
|
||||
}
|
||||
|
||||
function getSetupMessage(): string {
|
||||
if (!isBrowserUseEnabled()) {
|
||||
return 'Browser Use is disabled. Set CLOUDCLI_BROWSER_USE_ENABLED=1 after provisioning a Playwright/Chromium runtime.';
|
||||
}
|
||||
|
||||
return 'Playwright is not available in this runtime. Install/provision Playwright or point CloudCLI at a managed browser worker.';
|
||||
}
|
||||
|
||||
function getPlaywright(): any | null {
|
||||
try {
|
||||
return require('playwright');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getOwnerId(owner: BrowserUseOwner): string {
|
||||
if (owner.id === undefined || owner.id === null || String(owner.id).trim() === '') {
|
||||
throw new Error('Authenticated user is required.');
|
||||
}
|
||||
|
||||
return String(owner.id);
|
||||
}
|
||||
|
||||
function isPrivateIpv4(address: string): boolean {
|
||||
const parts = address.split('.').map((part) => Number.parseInt(part, 10));
|
||||
if (parts.length !== 4 || parts.some((part) => Number.isNaN(part) || part < 0 || part > 255)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const [first, second] = parts;
|
||||
return first === 0
|
||||
|| first === 10
|
||||
|| first === 127
|
||||
|| (first === 169 && second === 254)
|
||||
|| (first === 172 && second >= 16 && second <= 31)
|
||||
|| (first === 192 && second === 168)
|
||||
|| first >= 224;
|
||||
}
|
||||
|
||||
function isPrivateIpv6(address: string): boolean {
|
||||
const normalized = address.toLowerCase();
|
||||
return normalized === '::1'
|
||||
|| normalized === '::'
|
||||
|| normalized.startsWith('fc')
|
||||
|| normalized.startsWith('fd')
|
||||
|| normalized.startsWith('fe80:')
|
||||
|| normalized.startsWith('::ffff:127.')
|
||||
|| normalized.startsWith('::ffff:10.')
|
||||
|| normalized.startsWith('::ffff:192.168.')
|
||||
|| /^::ffff:172\.(1[6-9]|2\d|3[0-1])\./.test(normalized)
|
||||
|| /^::ffff:169\.254\./.test(normalized);
|
||||
}
|
||||
|
||||
export function isBlockedBrowserUseAddress(address: string): boolean {
|
||||
const version = net.isIP(address);
|
||||
if (version === 4) {
|
||||
return isPrivateIpv4(address);
|
||||
}
|
||||
if (version === 6) {
|
||||
return isPrivateIpv6(address);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
async function assertPublicHttpTarget(parsedUrl: URL): Promise<void> {
|
||||
if (ALLOW_PRIVATE_NETWORKS) {
|
||||
return;
|
||||
}
|
||||
|
||||
const hostname = parsedUrl.hostname;
|
||||
if (!hostname) {
|
||||
throw new Error('URL hostname is required.');
|
||||
}
|
||||
|
||||
if (net.isIP(hostname)) {
|
||||
if (isBlockedBrowserUseAddress(hostname)) {
|
||||
throw new Error('Browser Use cannot navigate to private or local network addresses.');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const addresses = await dns.lookup(hostname, { all: true, verbatim: true });
|
||||
if (addresses.length === 0 || addresses.some((entry) => isBlockedBrowserUseAddress(entry.address))) {
|
||||
throw new Error('Browser Use cannot navigate to private or local network addresses.');
|
||||
}
|
||||
}
|
||||
|
||||
async function normalizeUrl(rawUrl: string): Promise<string> {
|
||||
const trimmed = rawUrl.trim();
|
||||
if (!trimmed) {
|
||||
throw new Error('URL is required.');
|
||||
}
|
||||
|
||||
const withProtocol = /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(trimmed)
|
||||
? trimmed
|
||||
: `https://${trimmed}`;
|
||||
const parsed = new URL(withProtocol);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
throw new Error('Only http and https URLs are supported.');
|
||||
}
|
||||
|
||||
await assertPublicHttpTarget(parsed);
|
||||
|
||||
return parsed.toString();
|
||||
}
|
||||
|
||||
async function assertAllowedBrowserRequest(rawUrl: string): Promise<void> {
|
||||
const parsed = new URL(rawUrl);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await assertPublicHttpTarget(parsed);
|
||||
}
|
||||
|
||||
async function attachRequestGuard(page: any): Promise<void> {
|
||||
await page.route('**/*', async (route: any) => {
|
||||
try {
|
||||
await assertAllowedBrowserRequest(route.request().url());
|
||||
await route.continue();
|
||||
} catch {
|
||||
await route.abort('blockedbyclient');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function publicSession(session: BrowserUseSession): PublicBrowserUseSession {
|
||||
const { ownerId: _ownerId, ...publicFields } = session;
|
||||
return publicFields;
|
||||
}
|
||||
|
||||
function ownerSessions(ownerId: string): BrowserUseSession[] {
|
||||
return [...sessions.values()].filter((session) => session.ownerId === ownerId);
|
||||
}
|
||||
|
||||
async function closeHandle(sessionId: string): Promise<void> {
|
||||
const handle = handles.get(sessionId);
|
||||
handles.delete(sessionId);
|
||||
await handle?.browser?.close().catch(() => undefined);
|
||||
}
|
||||
|
||||
async function expireStaleSessions(now = Date.now()): Promise<void> {
|
||||
await Promise.all([...sessions.values()].map(async (session) => {
|
||||
if (session.status !== 'ready') {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedAt = Date.parse(session.updatedAt);
|
||||
if (!Number.isFinite(updatedAt) || now - updatedAt <= SESSION_TTL_MS) {
|
||||
return;
|
||||
}
|
||||
|
||||
await closeHandle(session.id);
|
||||
session.status = 'stopped';
|
||||
session.updatedAt = new Date(now).toISOString();
|
||||
session.lastAction = 'expire';
|
||||
session.message = 'Browser session expired after inactivity.';
|
||||
}));
|
||||
}
|
||||
|
||||
async function captureSession(session: BrowserUseSession, page: any): Promise<void> {
|
||||
const screenshot = await page.screenshot({ type: 'jpeg', quality: 72, fullPage: false });
|
||||
session.screenshotDataUrl = `data:image/jpeg;base64,${Buffer.from(screenshot).toString('base64')}`;
|
||||
session.title = await page.title().catch(() => null);
|
||||
session.url = page.url() || session.url;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
}
|
||||
|
||||
export const browserUseService = {
|
||||
getStatus() {
|
||||
const playwright = getPlaywright();
|
||||
const enabled = isBrowserUseEnabled() && Boolean(playwright);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
runtime: getRuntime(),
|
||||
available: enabled,
|
||||
sessionCount: sessions.size,
|
||||
mcpRecommended: true,
|
||||
message: enabled
|
||||
? 'Browser Use runtime is available.'
|
||||
: getSetupMessage(),
|
||||
};
|
||||
},
|
||||
|
||||
async listSessions(owner: BrowserUseOwner) {
|
||||
const ownerId = getOwnerId(owner);
|
||||
await expireStaleSessions();
|
||||
return ownerSessions(ownerId).map(publicSession);
|
||||
},
|
||||
|
||||
async createSession(owner: BrowserUseOwner) {
|
||||
const ownerId = getOwnerId(owner);
|
||||
await expireStaleSessions();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const session: BrowserUseSession = {
|
||||
id: randomUUID(),
|
||||
ownerId,
|
||||
runtime: getRuntime(),
|
||||
status: 'unavailable',
|
||||
url: null,
|
||||
title: null,
|
||||
screenshotDataUrl: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
lastAction: 'create',
|
||||
message: null,
|
||||
};
|
||||
|
||||
const activeOwnerSessions = ownerSessions(ownerId).filter((item) => item.status === 'ready');
|
||||
if (activeOwnerSessions.length >= MAX_SESSIONS_PER_OWNER) {
|
||||
throw new Error(`Browser Use is limited to ${MAX_SESSIONS_PER_OWNER} active sessions per user.`);
|
||||
}
|
||||
|
||||
const playwright = getPlaywright();
|
||||
if (!isBrowserUseEnabled() || !playwright) {
|
||||
session.message = getSetupMessage();
|
||||
sessions.set(session.id, session);
|
||||
return publicSession(session);
|
||||
}
|
||||
|
||||
const browser = await playwright.chromium.launch({
|
||||
headless: true,
|
||||
args: ['--disable-dev-shm-usage'],
|
||||
});
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 } });
|
||||
await attachRequestGuard(page);
|
||||
session.status = 'ready';
|
||||
session.message = 'Browser session is ready.';
|
||||
sessions.set(session.id, session);
|
||||
handles.set(session.id, { browser, page });
|
||||
await captureSession(session, page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async navigate(owner: BrowserUseOwner, sessionId: string, rawUrl: string) {
|
||||
const ownerId = getOwnerId(owner);
|
||||
await expireStaleSessions();
|
||||
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== ownerId) {
|
||||
throw new Error('Browser session not found.');
|
||||
}
|
||||
|
||||
if (session.status !== 'ready') {
|
||||
throw new Error(session.message || 'Browser session is not available.');
|
||||
}
|
||||
|
||||
const handle = handles.get(sessionId);
|
||||
if (!handle?.page) {
|
||||
throw new Error('Browser runtime handle is not available.');
|
||||
}
|
||||
|
||||
const url = await normalizeUrl(rawUrl);
|
||||
await handle.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||
session.lastAction = `navigate:${url}`;
|
||||
await captureSession(session, handle.page);
|
||||
return publicSession(session);
|
||||
},
|
||||
|
||||
async stopSession(owner: BrowserUseOwner, sessionId: string) {
|
||||
const ownerId = getOwnerId(owner);
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session || session.ownerId !== ownerId) {
|
||||
return { stopped: false };
|
||||
}
|
||||
|
||||
await closeHandle(sessionId);
|
||||
|
||||
session.status = 'stopped';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
session.lastAction = 'stop';
|
||||
session.message = 'Browser session stopped.';
|
||||
return { stopped: true, session: publicSession(session) };
|
||||
},
|
||||
|
||||
async stopAllSessions() {
|
||||
await Promise.all([...sessions.keys()].map(async (sessionId) => {
|
||||
await closeHandle(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
session.status = 'stopped';
|
||||
session.updatedAt = new Date().toISOString();
|
||||
session.lastAction = 'shutdown';
|
||||
session.message = 'Browser session stopped during server shutdown.';
|
||||
}
|
||||
}));
|
||||
},
|
||||
};
|
||||
|
||||
process.once('beforeExit', () => {
|
||||
void browserUseService.stopAllSessions();
|
||||
});
|
||||
41
server/modules/browser-use/tests/browser-use.service.test.ts
Normal file
41
server/modules/browser-use/tests/browser-use.service.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { browserUseService, isBlockedBrowserUseAddress } from '@/modules/browser-use/browser-use.service.js';
|
||||
|
||||
test('browser use blocks private and local network addresses by default', () => {
|
||||
assert.equal(isBlockedBrowserUseAddress('127.0.0.1'), true);
|
||||
assert.equal(isBlockedBrowserUseAddress('10.0.0.12'), true);
|
||||
assert.equal(isBlockedBrowserUseAddress('172.16.4.8'), true);
|
||||
assert.equal(isBlockedBrowserUseAddress('192.168.1.4'), true);
|
||||
assert.equal(isBlockedBrowserUseAddress('169.254.169.254'), true);
|
||||
assert.equal(isBlockedBrowserUseAddress('::1'), true);
|
||||
assert.equal(isBlockedBrowserUseAddress('8.8.8.8'), false);
|
||||
assert.equal(isBlockedBrowserUseAddress('2001:4860:4860::8888'), false);
|
||||
});
|
||||
|
||||
test('browser use sessions are listed only for their owner', async () => {
|
||||
const originalEnabled = process.env.CLOUDCLI_BROWSER_USE_ENABLED;
|
||||
process.env.CLOUDCLI_BROWSER_USE_ENABLED = '0';
|
||||
|
||||
const ownerA = { id: `owner-a-${Date.now()}-${Math.random()}` };
|
||||
const ownerB = { id: `owner-b-${Date.now()}-${Math.random()}` };
|
||||
|
||||
try {
|
||||
const ownerASession = await browserUseService.createSession(ownerA);
|
||||
await browserUseService.createSession(ownerB);
|
||||
|
||||
const ownerASessions = await browserUseService.listSessions(ownerA);
|
||||
const ownerBSessions = await browserUseService.listSessions(ownerB);
|
||||
|
||||
assert.equal(ownerASessions.some((session) => session.id === ownerASession.id), true);
|
||||
assert.equal(ownerBSessions.some((session) => session.id === ownerASession.id), false);
|
||||
assert.equal(Object.hasOwn(ownerASession, 'ownerId'), false);
|
||||
} finally {
|
||||
if (originalEnabled === undefined) {
|
||||
delete process.env.CLOUDCLI_BROWSER_USE_ENABLED;
|
||||
} else {
|
||||
process.env.CLOUDCLI_BROWSER_USE_ENABLED = originalEnabled;
|
||||
}
|
||||
}
|
||||
});
|
||||
1
src/components/browser-use/index.ts
Normal file
1
src/components/browser-use/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as BrowserUsePanel } from './view/BrowserUsePanel';
|
||||
233
src/components/browser-use/view/BrowserUsePanel.tsx
Normal file
233
src/components/browser-use/view/BrowserUsePanel.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { ExternalLink, Globe, MonitorPlay, Navigation, Pause, RefreshCw, Square } from 'lucide-react';
|
||||
|
||||
import { Badge, Button } from '../../../shared/view/ui';
|
||||
import { authenticatedFetch } from '../../../utils/api';
|
||||
|
||||
type BrowserUseStatus = {
|
||||
enabled: boolean;
|
||||
available: boolean;
|
||||
runtime: 'cloud' | 'local';
|
||||
sessionCount: number;
|
||||
mcpRecommended: boolean;
|
||||
message: string;
|
||||
};
|
||||
|
||||
type BrowserUseSession = {
|
||||
id: string;
|
||||
runtime: 'cloud' | 'local';
|
||||
status: 'ready' | 'stopped' | 'unavailable';
|
||||
url: string | null;
|
||||
title: string | null;
|
||||
screenshotDataUrl: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
lastAction: string | null;
|
||||
message: string | null;
|
||||
};
|
||||
|
||||
type BrowserUsePanelProps = {
|
||||
isVisible: boolean;
|
||||
};
|
||||
|
||||
async function readJson<T>(response: Response): Promise<T> {
|
||||
const data = await response.json();
|
||||
if (!response.ok || data.success === false) {
|
||||
throw new Error(data.error || data.details || `Request failed (${response.status})`);
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export default function BrowserUsePanel({ isVisible }: BrowserUsePanelProps) {
|
||||
const [status, setStatus] = useState<BrowserUseStatus | null>(null);
|
||||
const [sessions, setSessions] = useState<BrowserUseSession[]>([]);
|
||||
const [selectedSessionId, setSelectedSessionId] = useState<string | null>(null);
|
||||
const [targetUrl, setTargetUrl] = useState('https://example.com');
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const selectedSession = useMemo(
|
||||
() => sessions.find((session) => session.id === selectedSessionId) || sessions[0] || null,
|
||||
[selectedSessionId, sessions],
|
||||
);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
const [statusResponse, sessionsResponse] = await Promise.all([
|
||||
authenticatedFetch('/api/browser-use/status'),
|
||||
authenticatedFetch('/api/browser-use/sessions'),
|
||||
]);
|
||||
const statusData = await readJson<{ data: BrowserUseStatus }>(statusResponse);
|
||||
const sessionsData = await readJson<{ data: { sessions: BrowserUseSession[] } }>(sessionsResponse);
|
||||
setStatus(statusData.data);
|
||||
setSessions(sessionsData.data.sessions);
|
||||
setSelectedSessionId((current) => (
|
||||
current && sessionsData.data.sessions.some((session) => session.id === current)
|
||||
? current
|
||||
: sessionsData.data.sessions[0]?.id || null
|
||||
));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
void refresh().catch((err) => setError(err instanceof Error ? err.message : 'Failed to load Browser Use'));
|
||||
}, [isVisible, refresh]);
|
||||
|
||||
const runAction = useCallback(async (action: () => Promise<void>) => {
|
||||
setIsBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await action();
|
||||
await refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Browser Use action failed');
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
}, [refresh]);
|
||||
|
||||
const createSession = () => runAction(async () => {
|
||||
const response = await authenticatedFetch('/api/browser-use/sessions', { method: 'POST' });
|
||||
const data = await readJson<{ data: { session: BrowserUseSession } }>(response);
|
||||
setSelectedSessionId(data.data.session.id);
|
||||
});
|
||||
|
||||
const navigate = () => runAction(async () => {
|
||||
if (!selectedSession) {
|
||||
throw new Error('Create a browser session first.');
|
||||
}
|
||||
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/navigate`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url: targetUrl }),
|
||||
});
|
||||
await readJson(response);
|
||||
});
|
||||
|
||||
const stopSession = () => runAction(async () => {
|
||||
if (!selectedSession) return;
|
||||
const response = await authenticatedFetch(`/api/browser-use/sessions/${selectedSession.id}/stop`, { method: 'POST' });
|
||||
await readJson(response);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col bg-background">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border/60 px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorPlay className="h-4 w-4 text-primary" />
|
||||
<h3 className="text-sm font-semibold text-foreground">Browser Use</h3>
|
||||
{status && (
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{status.runtime}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
Managed Playwright browser sessions with owner-scoped screenshots and navigation.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => void refresh()} disabled={isBusy}>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
<Button size="sm" onClick={createSession} disabled={isBusy}>
|
||||
<Globe className="h-4 w-4" />
|
||||
New Session
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 grid-cols-1 lg:grid-cols-[280px_minmax(0,1fr)]">
|
||||
<aside className="border-b border-border/60 p-3 lg:border-b-0 lg:border-r">
|
||||
<div className="rounded-lg border border-border/70 bg-card/40 p-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Runtime</div>
|
||||
<div className="mt-2 text-sm text-foreground">{status?.available ? 'Available' : 'Setup required'}</div>
|
||||
<p className="mt-1 text-xs leading-relaxed text-muted-foreground">{status?.message || 'Loading Browser Use status...'}</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-2">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSessionId(session.id)}
|
||||
className={`w-full rounded-lg border px-3 py-2 text-left text-sm transition-colors ${selectedSession?.id === session.id
|
||||
? 'border-primary/50 bg-primary/10 text-foreground'
|
||||
: 'border-border/60 bg-card/30 text-muted-foreground hover:bg-muted/50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate font-medium">{session.title || session.url || 'Browser session'}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{session.status}</Badge>
|
||||
</div>
|
||||
<div className="mt-1 truncate text-xs">{session.url || session.message || session.id}</div>
|
||||
</button>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
<div className="rounded-lg border border-dashed border-border/70 px-3 py-8 text-center text-xs text-muted-foreground">
|
||||
No browser sessions yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="flex min-h-0 flex-col">
|
||||
<div className="flex flex-wrap items-center gap-2 border-b border-border/60 px-3 py-2">
|
||||
<input
|
||||
value={targetUrl}
|
||||
onChange={(event) => setTargetUrl(event.target.value)}
|
||||
className="h-9 min-w-[220px] flex-1 rounded-md border border-input bg-background px-3 text-sm outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder="https://example.com"
|
||||
/>
|
||||
<Button variant="outline" size="sm" onClick={navigate} disabled={isBusy || !selectedSession || selectedSession.status !== 'ready'}>
|
||||
<Navigation className="h-4 w-4" />
|
||||
Go
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<Pause className="h-4 w-4" />
|
||||
Pause
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={stopSession} disabled={isBusy || !selectedSession}>
|
||||
<Square className="h-4 w-4" />
|
||||
Stop
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="border-b border-red-200 bg-red-50 px-4 py-2 text-sm text-red-700 dark:border-red-900/50 dark:bg-red-950/30 dark:text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-muted/20 p-4">
|
||||
<div className="mx-auto flex min-h-[420px] max-w-6xl flex-col overflow-hidden rounded-lg border border-border bg-background shadow-sm">
|
||||
<div className="flex items-center gap-2 border-b border-border/60 px-3 py-2 text-xs text-muted-foreground">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{selectedSession?.url || 'No page loaded'}</span>
|
||||
</div>
|
||||
<div className="flex min-h-[360px] flex-1 items-center justify-center bg-neutral-950">
|
||||
{selectedSession?.screenshotDataUrl ? (
|
||||
<img
|
||||
src={selectedSession.screenshotDataUrl}
|
||||
alt="Browser session screenshot"
|
||||
className="h-full max-h-[70vh] w-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div className="max-w-md px-6 text-center">
|
||||
<MonitorPlay className="mx-auto h-10 w-10 text-neutral-500" />
|
||||
<div className="mt-3 text-sm font-medium text-neutral-100">
|
||||
{selectedSession?.message || 'Create a browser session to start.'}
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-relaxed text-neutral-400">
|
||||
This panel shows captured browser screenshots. Interactive agent control should use the guarded Browser Use API.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import FileTree from '../../file-tree/view/FileTree';
|
||||
import StandaloneShell from '../../standalone-shell/view/StandaloneShell';
|
||||
import GitPanel from '../../git-panel/view/GitPanel';
|
||||
import PluginTabContent from '../../plugins/view/PluginTabContent';
|
||||
import { BrowserUsePanel } from '../../browser-use';
|
||||
import type { MainContentProps } from '../types/types';
|
||||
import { useTaskMaster } from '../../../contexts/TaskMasterContext';
|
||||
import { usePaletteOpsRegister } from '../../../contexts/PaletteOpsContext';
|
||||
@@ -175,7 +176,11 @@ function MainContent({
|
||||
|
||||
{shouldShowTasksTab && <TaskMasterPanel isVisible={activeTab === 'tasks'} />}
|
||||
|
||||
<div className={`h-full overflow-hidden ${activeTab === 'preview' ? 'block' : 'hidden'}`} />
|
||||
{activeTab === 'browser' && (
|
||||
<div className="h-full overflow-hidden">
|
||||
<BrowserUsePanel isVisible={activeTab === 'browser'} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab.startsWith('plugin:') && (
|
||||
<div className="h-full overflow-hidden">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, type LucideIcon } from 'lucide-react';
|
||||
import { MessageSquare, Terminal, Folder, GitBranch, ClipboardCheck, MonitorPlay, type LucideIcon } from 'lucide-react';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { Tooltip, PillBar, Pill } from '../../../../shared/view/ui';
|
||||
import type { AppTab } from '../../../../types/app';
|
||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||
@@ -34,6 +35,7 @@ const BASE_TABS: BuiltInTab[] = [
|
||||
{ kind: 'builtin', id: 'shell', labelKey: 'tabs.shell', icon: Terminal },
|
||||
{ kind: 'builtin', id: 'files', labelKey: 'tabs.files', icon: Folder },
|
||||
{ kind: 'builtin', id: 'git', labelKey: 'tabs.git', icon: GitBranch },
|
||||
{ kind: 'builtin', id: 'browser', labelKey: 'tabs.browser', icon: MonitorPlay },
|
||||
];
|
||||
|
||||
const TASKS_TAB: BuiltInTab = {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import SessionProviderLogo from '../../../llm-logo-provider/SessionProviderLogo';
|
||||
import type { AppTab, Project, ProjectSession } from '../../../../types/app';
|
||||
import { usePlugins } from '../../../../contexts/PluginsContext';
|
||||
@@ -27,6 +28,10 @@ function getTabTitle(activeTab: AppTab, shouldShowTasksTab: boolean, t: (key: st
|
||||
return 'TaskMaster';
|
||||
}
|
||||
|
||||
if (activeTab === 'browser') {
|
||||
return 'Browser Use';
|
||||
}
|
||||
|
||||
return 'Project';
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,7 @@ const isUpdateAdditive = (
|
||||
);
|
||||
};
|
||||
|
||||
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'preview']);
|
||||
const VALID_TABS: Set<string> = new Set(['chat', 'files', 'shell', 'git', 'tasks', 'browser']);
|
||||
|
||||
const isValidTab = (tab: string): tab is AppTab => {
|
||||
return VALID_TABS.has(tab) || tab.startsWith('plugin:');
|
||||
@@ -631,7 +631,7 @@ export function useProjectsState({
|
||||
(session: ProjectSession) => {
|
||||
setSelectedSession(session);
|
||||
|
||||
if (activeTab === 'tasks' || activeTab === 'preview') {
|
||||
if (activeTab === 'tasks' || activeTab === 'browser') {
|
||||
setActiveTab('chat');
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"shell": "Terminal",
|
||||
"files": "Dateien",
|
||||
"git": "Quellcodeverwaltung",
|
||||
"tasks": "Aufgaben"
|
||||
"tasks": "Aufgaben",
|
||||
"browser": "Browser"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Lädt...",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"shell": "Shell",
|
||||
"files": "Files",
|
||||
"git": "Source Control",
|
||||
"tasks": "Tasks"
|
||||
"tasks": "Tasks",
|
||||
"browser": "Browser"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Loading...",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"shell": "Terminale",
|
||||
"files": "File",
|
||||
"git": "Controllo Versione",
|
||||
"tasks": "Attività"
|
||||
"tasks": "Attività",
|
||||
"browser": "Browser"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Caricamento...",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"shell": "シェル",
|
||||
"files": "ファイル",
|
||||
"git": "ソース管理",
|
||||
"tasks": "タスク"
|
||||
"tasks": "タスク",
|
||||
"browser": "Browser"
|
||||
},
|
||||
"status": {
|
||||
"loading": "読み込み中...",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"shell": "Shell",
|
||||
"files": "파일",
|
||||
"git": "소스 관리",
|
||||
"tasks": "작업"
|
||||
"tasks": "작업",
|
||||
"browser": "Browser"
|
||||
},
|
||||
"status": {
|
||||
"loading": "로딩 중...",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"shell": "Терминал",
|
||||
"files": "Файлы",
|
||||
"git": "Система контроля версий",
|
||||
"tasks": "Задачи"
|
||||
"tasks": "Задачи",
|
||||
"browser": "Browser"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Загрузка...",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"shell": "Shell",
|
||||
"files": "Dosyalar",
|
||||
"git": "Kaynak Kontrolü",
|
||||
"tasks": "Görevler"
|
||||
"tasks": "Görevler",
|
||||
"browser": "Browser"
|
||||
},
|
||||
"status": {
|
||||
"loading": "Yükleniyor...",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"shell": "终端",
|
||||
"files": "文件",
|
||||
"git": "源代码管理",
|
||||
"tasks": "任务"
|
||||
"tasks": "任务",
|
||||
"browser": "Browser"
|
||||
},
|
||||
"status": {
|
||||
"loading": "加载中...",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"shell": "終端機",
|
||||
"files": "檔案",
|
||||
"git": "版本控制",
|
||||
"tasks": "任務"
|
||||
"tasks": "任務",
|
||||
"browser": "Browser"
|
||||
},
|
||||
"status": {
|
||||
"loading": "載入中...",
|
||||
|
||||
@@ -17,7 +17,7 @@ export type ProviderModelsCacheInfo = {
|
||||
source: 'memory' | 'disk' | 'fresh';
|
||||
};
|
||||
|
||||
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'preview' | `plugin:${string}`;
|
||||
export type AppTab = 'chat' | 'files' | 'shell' | 'git' | 'tasks' | 'browser' | `plugin:${string}`;
|
||||
|
||||
export interface ProjectSession {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user