This commit is contained in:
puzhen 2025-11-19 21:56:18 +08:00
parent aa2dba17f1
commit f67116a65d
6 changed files with 327 additions and 46 deletions

View file

@ -48,6 +48,7 @@ class Chat(BaseModel):
language: str = "en"
browser_port: int = 9222
use_external_cdp: bool = False
cdp_browsers: list[dict] = []
max_retries: int = 3
allow_local_system: bool = False
installed_mcp: McpServers = {"mcpServers": {}}

View file

@ -70,6 +70,9 @@ from app.service.task import set_process_task
NOW_STR = datetime.datetime.now().strftime("%Y-%m-%d %H:00:00")
# Global counter for round-robin browser selection from pool
_browser_selection_counter = 0
class ListenChatAgent(ChatAgent):
@traceroot.trace()
@ -729,9 +732,31 @@ def search_agent(options: Chat):
message_handler=HumanToolkit(options.project_id, Agents.search_agent).send_message_to_user
)
# Browser selection logic from CDP browser pool
selected_port = env('browser_port', '9222')
selected_is_external = options.use_external_cdp if hasattr(options, 'use_external_cdp') else False
# If CDP browser pool is available and not empty, select a browser from the pool
if hasattr(options, 'cdp_browsers') and options.cdp_browsers:
global _browser_selection_counter
# Use round-robin selection from the pool
selected_browser = options.cdp_browsers[_browser_selection_counter % len(options.cdp_browsers)]
_browser_selection_counter += 1
selected_port = selected_browser.get('port', selected_port)
selected_is_external = selected_browser.get('isExternal', False)
traceroot_logger.info(
f"Selected browser from pool: port={selected_port}, "
f"isExternal={selected_is_external}, "
f"name={selected_browser.get('name', 'Unnamed')}"
)
else:
traceroot_logger.info(f"No CDP browser pool available, using default port: {selected_port}")
# Use cdp_keep_current_page=True only when using external CDP browser
# to preserve the current page. For internal browser, use False (default behavior)
use_keep_current_page = options.use_external_cdp if hasattr(options, 'use_external_cdp') else False
use_keep_current_page = selected_is_external
# When cdp_keep_current_page=True, don't set default_start_url (conflicts with keeping current page)
# When cdp_keep_current_page=False, use "about:blank" as default start URL
@ -745,7 +770,7 @@ def search_agent(options: Chat):
session_id=str(uuid.uuid4())[:8],
default_start_url=default_url,
connect_over_cdp=True,
cdp_url=f"http://localhost:{env('browser_port', '9222')}",
cdp_url=f"http://localhost:{selected_port}",
cdp_keep_current_page=use_keep_current_page,
enabled_tools=[
"browser_open",

View file

@ -40,7 +40,19 @@ let python_process: ChildProcessWithoutNullStreams | null = null;
let backendPort: number = 5001;
let browser_port = 9222;
let use_external_cdp = false; // Flag to track if using external CDP browser
let cdp_browser_process: ChildProcessWithoutNullStreams | null = null;
// CDP Browser Pool
interface CdpBrowser {
id: string;
port: number;
isExternal: boolean;
name?: string;
addedAt: number;
}
let cdp_browser_pool: CdpBrowser[] = [];
// Map to store multiple browser processes by port
let cdp_browser_processes: Map<number, ChildProcessWithoutNullStreams> = new Map();
// Protocol URL queue for handling URLs before window is ready
let protocolUrlQueue: string[] = [];
@ -290,6 +302,89 @@ function registerIpcHandlers() {
return use_external_cdp
});
// ==================== CDP Browser Pool Management ====================
// Get all browsers in the pool
ipcMain.handle('get-cdp-browsers', () => {
log.info(`Getting CDP browser pool, count: ${cdp_browser_pool.length}`)
return cdp_browser_pool
});
// Get running browser processes
ipcMain.handle('get-running-browser-ports', () => {
const runningPorts = Array.from(cdp_browser_processes.keys());
log.info(`Getting running browser ports: ${runningPorts.join(', ')}`)
return runningPorts;
});
// Add browser to pool
ipcMain.handle('add-cdp-browser', (event, port: number, isExternal: boolean, name?: string) => {
log.info(`Adding CDP browser: port=${port}, external=${isExternal}, name=${name}`)
// Check if browser with this port already exists
const existing = cdp_browser_pool.find(b => b.port === port);
if (existing) {
return { success: false, error: 'Browser with this port already exists' };
}
const newBrowser: CdpBrowser = {
id: `cdp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
port,
isExternal,
name,
addedAt: Date.now(),
};
cdp_browser_pool.push(newBrowser);
log.info(`Browser added to pool, new count: ${cdp_browser_pool.length}`)
return { success: true, browser: newBrowser };
});
// Remove browser from pool
ipcMain.handle('remove-cdp-browser', (event, browserId: string) => {
log.info(`Removing CDP browser: ${browserId}`)
const index = cdp_browser_pool.findIndex(b => b.id === browserId);
if (index === -1) {
return { success: false, error: 'Browser not found' };
}
const removed = cdp_browser_pool.splice(index, 1)[0];
// If it's a launched browser, kill the process
if (!removed.isExternal && cdp_browser_processes.has(removed.port)) {
log.info(`Killing browser process on port ${removed.port}`);
try {
const process = cdp_browser_processes.get(removed.port);
process?.kill();
cdp_browser_processes.delete(removed.port);
} catch (error) {
log.warn(`Failed to kill browser process on port ${removed.port}: ${error}`);
}
}
log.info(`Browser removed from pool, remaining count: ${cdp_browser_pool.length}`)
return { success: true, browser: removed };
});
// Update browser in pool
ipcMain.handle('update-cdp-browser', (event, browserId: string, updates: Partial<CdpBrowser>) => {
log.info(`Updating CDP browser: ${browserId}`)
const browser = cdp_browser_pool.find(b => b.id === browserId);
if (!browser) {
return { success: false, error: 'Browser not found' };
}
// Update allowed fields
if (updates.name !== undefined) browser.name = updates.name;
log.info(`Browser updated in pool`)
return { success: true, browser };
});
// Check if CDP port is available
ipcMain.handle('check-cdp-port', async (event, port: number) => {
log.info(`Checking CDP port availability: ${port}`);
@ -358,32 +453,25 @@ function registerIpcHandlers() {
};
}
// Create/clear user data directory
const userDataDir = path.join(app.getPath('userData'), 'cdp_browser_profile');
// Create user data directory with port number in name
// This allows multiple browsers on different ports to maintain separate profiles
const userDataDir = path.join(app.getPath('userData'), `cdp_browser_profile_${port}`);
// Clear the directory if it exists and is not empty
if (existsSync(userDataDir)) {
log.info(`Clearing existing user data directory: ${userDataDir}`);
try {
await fsp.rm(userDataDir, { recursive: true, force: true });
} catch (error) {
log.warn(`Failed to clear user data directory: ${error}`);
}
// Create directory if it doesn't exist (preserve existing data)
if (!existsSync(userDataDir)) {
await fsp.mkdir(userDataDir, { recursive: true });
log.info(`Created new user data directory: ${userDataDir}`);
} else {
log.info(`Using existing user data directory: ${userDataDir}`);
}
// Create fresh directory
await fsp.mkdir(userDataDir, { recursive: true });
log.info(`Created fresh user data directory: ${userDataDir}`);
// Kill existing CDP browser process if any
if (cdp_browser_process) {
log.info('Killing existing CDP browser process');
try {
cdp_browser_process.kill();
} catch (error) {
log.warn(`Failed to kill existing process: ${error}`);
}
cdp_browser_process = null;
// Check if browser on this port is already running
if (cdp_browser_processes.has(port)) {
log.warn(`Browser process already exists on port ${port}`);
return {
success: false,
error: `Browser already running on port ${port}`,
};
}
// Chrome launch arguments
@ -399,21 +487,24 @@ function registerIpcHandlers() {
log.info(`Launching Chrome with args: ${args.join(' ')}`);
// Spawn Chrome process
cdp_browser_process = spawn(chromeExecutable, args, {
const browserProcess = spawn(chromeExecutable, args, {
detached: false,
stdio: 'ignore',
});
cdp_browser_process.on('error', (error) => {
log.error(`CDP browser process error: ${error}`);
cdp_browser_process = null;
browserProcess.on('error', (error) => {
log.error(`CDP browser process on port ${port} error: ${error}`);
cdp_browser_processes.delete(port);
});
cdp_browser_process.on('exit', (code) => {
log.info(`CDP browser process exited with code ${code}`);
cdp_browser_process = null;
browserProcess.on('exit', (code) => {
log.info(`CDP browser process on port ${port} exited with code ${code}`);
cdp_browser_processes.delete(port);
});
// Store the process in the Map
cdp_browser_processes.set(port, browserProcess);
// Wait a bit for browser to start
await new Promise(resolve => setTimeout(resolve, 2000));

View file

@ -93,6 +93,12 @@ contextBridge.exposeInMainWorld('electronAPI', {
launchCdpBrowser: (port: number) => ipcRenderer.invoke('launch-cdp-browser', port),
setBrowserPort: (port: number, isExternal?: boolean) => ipcRenderer.invoke('set-browser-port', port, isExternal),
getUseExternalCdp: () => ipcRenderer.invoke('get-use-external-cdp'),
// CDP Browser Pool
getCdpBrowsers: () => ipcRenderer.invoke('get-cdp-browsers'),
getRunningBrowserPorts: () => ipcRenderer.invoke('get-running-browser-ports'),
addCdpBrowser: (port: number, isExternal: boolean, name?: string) => ipcRenderer.invoke('add-cdp-browser', port, isExternal, name),
removeCdpBrowser: (browserId: string) => ipcRenderer.invoke('remove-cdp-browser', browserId),
updateCdpBrowser: (browserId: string, updates: any) => ipcRenderer.invoke('update-cdp-browser', browserId, updates),
});

View file

@ -26,6 +26,14 @@ interface CdpPortStatus {
data?: any;
}
interface CdpBrowser {
id: string;
port: number;
isExternal: boolean;
name?: string;
addedAt: number;
}
export default function Browser() {
const { t } = useTranslation();
const [loginLoading, setLoginLoading] = useState(false);
@ -50,6 +58,11 @@ export default function Browser() {
const [showLaunchNewDialog, setShowLaunchNewDialog] = useState(false);
const [pendingPort, setPendingPort] = useState<number | null>(null);
// CDP Browser Pool
const [cdpBrowsers, setCdpBrowsers] = useState<CdpBrowser[]>([]);
const [deletingBrowser, setDeletingBrowser] = useState<string | null>(null);
const [runningPorts, setRunningPorts] = useState<number[]>([]);
// Extract main domain (e.g., "aa.bb.cc" -> "bb.cc", "www.google.com" -> "google.com")
const getMainDomain = (domain: string): string => {
// Remove leading dot if present
@ -89,6 +102,8 @@ export default function Browser() {
handleLoadCookies();
// Load current browser port on mount
loadCurrentBrowserPort();
// Load CDP browser pool
loadCdpBrowsers();
}, []);
const loadCurrentBrowserPort = async () => {
@ -99,6 +114,39 @@ export default function Browser() {
}
};
const loadCdpBrowsers = async () => {
if (window.electronAPI?.getCdpBrowsers) {
try {
const browsers = await window.electronAPI.getCdpBrowsers();
setCdpBrowsers(browsers);
// Also load running browser ports
if (window.electronAPI?.getRunningBrowserPorts) {
const ports = await window.electronAPI.getRunningBrowserPorts();
setRunningPorts(ports);
}
} catch (error) {
console.error("Failed to load CDP browsers:", error);
}
}
};
// Periodically refresh running browser ports
useEffect(() => {
const interval = setInterval(async () => {
if (window.electronAPI?.getRunningBrowserPorts) {
try {
const ports = await window.electronAPI.getRunningBrowserPorts();
setRunningPorts(ports);
} catch (error) {
console.error("Failed to refresh running ports:", error);
}
}
}, 3000); // Refresh every 3 seconds
return () => clearInterval(interval);
}, []);
const handleCheckPort = async () => {
const portNumber = parseInt(customPort);
@ -151,15 +199,18 @@ export default function Browser() {
setShowUseExistingDialog(false);
if (pendingPort) {
try {
// Update the browser port in electron
// isExternal=true because we're using an existing external browser
if (window.electronAPI?.setBrowserPort) {
await window.electronAPI.setBrowserPort(pendingPort, true);
// Add browser to pool
if (window.electronAPI?.addCdpBrowser) {
const result = await window.electronAPI.addCdpBrowser(pendingPort, true, `External Browser (${pendingPort})`);
if (result.success) {
toast.success(`Added external browser on port ${pendingPort} to pool`);
await loadCdpBrowsers();
} else {
toast.error(result.error || "Failed to add browser to pool");
}
}
setCdpPort(pendingPort);
toast.success(`Now using external browser on port ${pendingPort}`);
} catch (error: any) {
toast.error(error.message || "Failed to set browser port");
toast.error(error.message || "Failed to add browser to pool");
}
}
setPendingPort(null);
@ -186,13 +237,18 @@ export default function Browser() {
const result = await window.electronAPI.launchCdpBrowser(port);
if (result.success) {
// Update the browser port in electron
// isExternal=false because this is our own launched browser
if (window.electronAPI?.setBrowserPort) {
await window.electronAPI.setBrowserPort(port, false);
}
setCdpPort(port);
toast.success(`Browser launched successfully on port ${port}`, { id: 'launch-browser' });
// Add launched browser to pool
if (window.electronAPI?.addCdpBrowser) {
const addResult = await window.electronAPI.addCdpBrowser(port, false, `Launched Browser (${port})`);
if (addResult.success) {
await loadCdpBrowsers();
} else {
toast.error(addResult.error || "Failed to add browser to pool");
}
}
// Update port status
setPortStatus({
checking: false,
@ -207,6 +263,25 @@ export default function Browser() {
}
};
const handleRemoveBrowser = async (browserId: string) => {
setDeletingBrowser(browserId);
try {
if (window.electronAPI?.removeCdpBrowser) {
const result = await window.electronAPI.removeCdpBrowser(browserId);
if (result.success) {
toast.success("Browser removed from pool");
await loadCdpBrowsers();
} else {
toast.error(result.error || "Failed to remove browser");
}
}
} catch (error: any) {
toast.error(error.message || "Failed to remove browser");
} finally {
setDeletingBrowser(null);
}
};
const handleBrowserLogin = async () => {
setLoginLoading(true);
try {
@ -507,6 +582,87 @@ export default function Browser() {
</div>
</div>
{/* CDP Browser Pool Section */}
<div className="flex flex-col max-w-[600px] w-full gap-3 border-[0.5px] border-border-secondary border-b-0 border-x-0 border-solid pt-3 mt-3">
<div className="flex flex-row items-center justify-between py-2">
<div className="flex flex-col items-start">
<div className="flex items-center gap-2">
<div className="text-body-base font-bold text-text-body">
CDP Browser Pool
</div>
<span className="text-label-xs px-2 py-0.5 rounded bg-tag-fill-info text-text-information">
{runningPorts.length} / {cdpBrowsers.length} Running
</span>
</div>
<p className="text-label-xs text-text-label mt-1">
Manage multiple CDP browsers for task execution
</p>
</div>
</div>
{cdpBrowsers.length > 0 ? (
<div className="flex flex-col gap-2">
{cdpBrowsers.map((browser) => (
<div
key={browser.id}
className="flex items-center justify-between px-4 py-3 bg-surface-tertiary rounded-xl border-solid border-border-disabled"
>
<div className="flex flex-col w-full items-start justify-start">
<div className="flex items-center gap-2">
<span className="text-body-sm text-text-body font-bold">
{browser.name || `Browser ${browser.port}`}
</span>
<span className={`text-label-xs px-2 py-0.5 rounded ${
browser.isExternal
? 'bg-tag-fill-info text-text-information'
: 'bg-tag-fill-success text-text-success'
}`}>
{browser.isExternal ? 'External' : 'Launched'}
</span>
{/* Running status indicator */}
{runningPorts.includes(browser.port) ? (
<span className="flex items-center gap-1 text-label-xs px-2 py-0.5 rounded bg-tag-fill-success text-text-success">
<span className="w-2 h-2 rounded-full bg-text-success animate-pulse"></span>
Running
</span>
) : (
!browser.isExternal && (
<span className="flex items-center gap-1 text-label-xs px-2 py-0.5 rounded bg-tag-fill-error text-text-cuation">
<span className="w-2 h-2 rounded-full bg-text-cuation"></span>
Stopped
</span>
)
)}
</div>
<span className="text-label-xs text-text-label mt-1">
Port: {browser.port}
</span>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveBrowser(browser.id)}
disabled={deletingBrowser === browser.id}
className="ml-3 flex-shrink-0"
>
<Trash2 className="w-4 h-4 text-text-cuation" />
</Button>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center py-8 px-4 bg-surface-tertiary rounded-xl">
<Globe className="w-12 h-12 text-icon-secondary opacity-50 mb-4" />
<div className="text-body-base font-bold text-text-label text-center">
No browsers in pool
</div>
<p className="text-label-xs font-medium text-text-label text-center mt-1">
Add browsers using the check port tool above
</p>
</div>
)}
</div>
{/* Cookies Section */}
<div className="flex flex-col max-w-[600px] w-full gap-3 border-[0.5px] border-border-secondary border-b-0 border-x-0 border-solid pt-3 mt-3">

View file

@ -382,6 +382,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
}
const browser_port = await window.ipcRenderer.invoke('get-browser-port');
const use_external_cdp = await window.ipcRenderer.invoke('get-use-external-cdp');
const cdp_browsers = await window.ipcRenderer.invoke('get-cdp-browsers');
// Lock the chatStore reference at the start of SSE session to prevent focus changes
// during active message processing
@ -428,6 +429,7 @@ const chatStore = (initial?: Partial<ChatStore>) => createStore<ChatStore>()(
new_agents: [...addWorkers],
browser_port: browser_port,
use_external_cdp: use_external_cdp,
cdp_browsers: cdp_browsers,
env_path: envPath,
search_config: searchConfig
}) : undefined,