camel-ai 0.2.71a11__py3-none-any.whl → 0.2.72__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of camel-ai might be problematic. Click here for more details.

Files changed (46) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/chat_agent.py +261 -489
  3. camel/memories/agent_memories.py +39 -0
  4. camel/memories/base.py +8 -0
  5. camel/models/gemini_model.py +30 -2
  6. camel/models/moonshot_model.py +36 -4
  7. camel/models/openai_model.py +29 -15
  8. camel/societies/workforce/prompts.py +25 -15
  9. camel/societies/workforce/role_playing_worker.py +1 -1
  10. camel/societies/workforce/single_agent_worker.py +9 -7
  11. camel/societies/workforce/worker.py +1 -1
  12. camel/societies/workforce/workforce.py +97 -34
  13. camel/storages/vectordb_storages/__init__.py +1 -0
  14. camel/storages/vectordb_storages/surreal.py +415 -0
  15. camel/tasks/task.py +9 -5
  16. camel/toolkits/__init__.py +10 -1
  17. camel/toolkits/base.py +57 -1
  18. camel/toolkits/human_toolkit.py +5 -1
  19. camel/toolkits/hybrid_browser_toolkit/config_loader.py +127 -414
  20. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +783 -1626
  21. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +489 -0
  22. camel/toolkits/markitdown_toolkit.py +2 -2
  23. camel/toolkits/message_integration.py +592 -0
  24. camel/toolkits/note_taking_toolkit.py +195 -26
  25. camel/toolkits/openai_image_toolkit.py +5 -5
  26. camel/toolkits/origene_mcp_toolkit.py +97 -0
  27. camel/toolkits/screenshot_toolkit.py +213 -0
  28. camel/toolkits/search_toolkit.py +161 -79
  29. camel/toolkits/terminal_toolkit.py +379 -165
  30. camel/toolkits/video_analysis_toolkit.py +13 -13
  31. camel/toolkits/video_download_toolkit.py +11 -11
  32. camel/toolkits/web_deploy_toolkit.py +1024 -0
  33. camel/types/enums.py +6 -3
  34. camel/types/unified_model_type.py +16 -4
  35. camel/utils/mcp_client.py +8 -0
  36. camel/utils/tool_result.py +1 -1
  37. {camel_ai-0.2.71a11.dist-info → camel_ai-0.2.72.dist-info}/METADATA +6 -3
  38. {camel_ai-0.2.71a11.dist-info → camel_ai-0.2.72.dist-info}/RECORD +40 -40
  39. camel/toolkits/hybrid_browser_toolkit/actions.py +0 -417
  40. camel/toolkits/hybrid_browser_toolkit/agent.py +0 -311
  41. camel/toolkits/hybrid_browser_toolkit/browser_session.py +0 -739
  42. camel/toolkits/hybrid_browser_toolkit/snapshot.py +0 -227
  43. camel/toolkits/hybrid_browser_toolkit/stealth_script.js +0 -0
  44. camel/toolkits/hybrid_browser_toolkit/unified_analyzer.js +0 -1002
  45. {camel_ai-0.2.71a11.dist-info → camel_ai-0.2.72.dist-info}/WHEEL +0 -0
  46. {camel_ai-0.2.71a11.dist-info → camel_ai-0.2.72.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,1024 @@
1
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
+
15
+ from __future__ import annotations
16
+
17
+ import base64
18
+ import json
19
+ import mimetypes
20
+ import os
21
+ import re
22
+ import shutil
23
+ import socket
24
+ import subprocess
25
+ import tempfile
26
+ import time
27
+ from typing import Any, Dict, List, Optional
28
+
29
+ from camel.logger import get_logger
30
+ from camel.toolkits import FunctionTool
31
+ from camel.toolkits.base import BaseToolkit
32
+
33
+ logger = get_logger(__name__)
34
+
35
+
36
+ class WebDeployToolkit(BaseToolkit):
37
+ r"""A simple toolkit for initializing React projects and deploying web.
38
+
39
+ This toolkit provides core functionality to:
40
+ - Initialize new React projects
41
+ - Build React applications
42
+ - Deploy HTML content to local server
43
+ - Serve static websites locally
44
+ """
45
+
46
+ def __init__(
47
+ self,
48
+ timeout: Optional[float] = None,
49
+ add_branding_tag: bool = True,
50
+ logo_path: str = "../camel/misc/favicon.png",
51
+ tag_text: str = "Created by CAMEL",
52
+ tag_url: str = "https://github.com/camel-ai/camel",
53
+ remote_server_ip: Optional[str] = None,
54
+ remote_server_port: int = 8080,
55
+ ):
56
+ r"""Initialize the WebDeployToolkit.
57
+
58
+ Args:
59
+ timeout (Optional[float]): Command timeout in seconds.
60
+ (default: :obj:`None`)
61
+ add_branding_tag (bool): Whether to add brand tag to deployed
62
+ pages. (default: :obj:`True`)
63
+ logo_path (str): Path to custom logo file (SVG, PNG, JPG, ICO).
64
+ (default: :obj:`../camel/misc/favicon.png`)
65
+ tag_text (str): Text to display in the tag.
66
+ (default: :obj:`Created by CAMEL`)
67
+ tag_url (str): URL to open when tag is clicked.
68
+ (default: :obj:`https://github.com/camel-ai/camel`)
69
+ remote_server_ip (Optional[str]): Remote server IP for deployment.
70
+ (default: :obj:`None` - use local deployment)
71
+ remote_server_port (int): Remote server port.
72
+ (default: :obj:`8080`)
73
+ """
74
+ super().__init__(timeout=timeout)
75
+ self.timeout = timeout
76
+ self.server_instances: Dict[int, Any] = {} # Track running servers
77
+ self.add_branding_tag = add_branding_tag
78
+ self.logo_path = logo_path
79
+ self.tag_text = self._sanitize_text(tag_text)
80
+ self.tag_url = self._validate_url(tag_url)
81
+ self.remote_server_ip = (
82
+ self._validate_ip(remote_server_ip) if remote_server_ip else None
83
+ )
84
+ self.remote_server_port = self._validate_port(remote_server_port)
85
+ self.server_registry_file = os.path.join(
86
+ tempfile.gettempdir(), "web_deploy_servers.json"
87
+ )
88
+ self._load_server_registry()
89
+
90
+ def _validate_ip(self, ip: str) -> str:
91
+ """Validate IP address format."""
92
+ import ipaddress
93
+
94
+ try:
95
+ ipaddress.ip_address(ip)
96
+ return ip
97
+ except ValueError:
98
+ raise ValueError(f"Invalid IP address: {ip}")
99
+
100
+ def _validate_port(self, port: int) -> int:
101
+ """Validate port number."""
102
+ if not isinstance(port, int) or port < 1 or port > 65535:
103
+ raise ValueError(f"Invalid port number: {port}")
104
+ return port
105
+
106
+ def _sanitize_text(self, text: str) -> str:
107
+ """Sanitize text to prevent XSS."""
108
+ if not isinstance(text, str):
109
+ return ""
110
+ # Remove any HTML/script tags
111
+ text = re.sub(r'<[^>]+>', '', text)
112
+ # Escape special characters
113
+ text = (
114
+ text.replace('&', '&amp;')
115
+ .replace('<', '&lt;')
116
+ .replace('>', '&gt;')
117
+ )
118
+ text = text.replace('"', '&quot;').replace("'", '&#x27;')
119
+ return text[:100] # Limit length
120
+
121
+ def _validate_url(self, url: str) -> str:
122
+ """Validate URL format."""
123
+ if not isinstance(url, str):
124
+ raise ValueError("URL must be a string")
125
+ # Basic URL validation
126
+ url_pattern = re.compile(
127
+ r'^https?://'
128
+ r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+[A-Z]{2,6}\.?|'
129
+ r'localhost|'
130
+ r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
131
+ r'(?::\d+)?'
132
+ r'(?:/?|[/?]\S+)$',
133
+ re.IGNORECASE,
134
+ )
135
+ if not url_pattern.match(url):
136
+ raise ValueError(f"Invalid URL format: {url}")
137
+ return url
138
+
139
+ def _validate_subdirectory(
140
+ self, subdirectory: Optional[str]
141
+ ) -> Optional[str]:
142
+ """Validate subdirectory to prevent path traversal."""
143
+ if subdirectory is None:
144
+ return None
145
+
146
+ # Remove any leading/trailing slashes
147
+ subdirectory = subdirectory.strip('/')
148
+
149
+ # Check for path traversal attempts
150
+ if '..' in subdirectory or subdirectory.startswith('/'):
151
+ raise ValueError(f"Invalid subdirectory: {subdirectory}")
152
+
153
+ # Only allow alphanumeric, dash, underscore, and forward slashes
154
+ if not re.match(r'^[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)*$', subdirectory):
155
+ raise ValueError(f"Invalid subdirectory format: {subdirectory}")
156
+
157
+ return subdirectory
158
+
159
+ def _is_port_available(self, port: int) -> bool:
160
+ """Check if a port is available for binding."""
161
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
162
+ try:
163
+ sock.bind(('127.0.0.1', port))
164
+ return True
165
+ except OSError:
166
+ return False
167
+
168
+ def _load_server_registry(self):
169
+ r"""Load server registry from persistent storage."""
170
+ try:
171
+ if os.path.exists(self.server_registry_file):
172
+ with open(self.server_registry_file, 'r') as f:
173
+ data = json.load(f)
174
+ # Reconstruct server instances from registry
175
+ for port_str, server_info in data.items():
176
+ port = int(port_str)
177
+ pid = server_info.get('pid')
178
+ if pid and self._is_process_running(pid):
179
+ # Create a mock process object for tracking
180
+ self.server_instances[port] = {
181
+ 'pid': pid,
182
+ 'start_time': server_info.get('start_time'),
183
+ 'directory': server_info.get('directory'),
184
+ }
185
+ except Exception as e:
186
+ logger.warning(f"Could not load server registry: {e}")
187
+
188
+ def _save_server_registry(self):
189
+ r"""Save server registry to persistent storage."""
190
+ try:
191
+ registry_data = {}
192
+ for port, server_info in self.server_instances.items():
193
+ if isinstance(server_info, dict):
194
+ registry_data[str(port)] = {
195
+ 'pid': server_info.get('pid'),
196
+ 'start_time': server_info.get('start_time'),
197
+ 'directory': server_info.get('directory'),
198
+ }
199
+ else:
200
+ # Handle subprocess.Popen objects
201
+ registry_data[str(port)] = {
202
+ 'pid': server_info.pid,
203
+ 'start_time': time.time(),
204
+ 'directory': getattr(server_info, 'directory', None),
205
+ }
206
+
207
+ with open(self.server_registry_file, 'w') as f:
208
+ json.dump(registry_data, f, indent=2)
209
+ except Exception as e:
210
+ logger.warning(f"Could not save server registry: {e}")
211
+
212
+ def _is_process_running(self, pid: int) -> bool:
213
+ r"""Check if a process with given PID is still running."""
214
+ try:
215
+ # Send signal 0 to check if process exists
216
+ os.kill(pid, 0)
217
+ return True
218
+ except (OSError, ProcessLookupError):
219
+ return False
220
+
221
+ def _build_custom_url(
222
+ self, domain: str, subdirectory: Optional[str] = None
223
+ ) -> str:
224
+ r"""Build custom URL with optional subdirectory.
225
+
226
+ Args:
227
+ domain (str): Custom domain
228
+ subdirectory (Optional[str]): Subdirectory path
229
+
230
+ Returns:
231
+ str: Complete custom URL
232
+ """
233
+ # Validate domain
234
+ if not re.match(r'^[a-zA-Z0-9.-]+$', domain):
235
+ raise ValueError(f"Invalid domain format: {domain}")
236
+ custom_url = f"http://{domain}:8080"
237
+ if subdirectory:
238
+ subdirectory = self._validate_subdirectory(subdirectory)
239
+ custom_url += f"/{subdirectory}"
240
+ return custom_url
241
+
242
+ def _load_logo_as_data_uri(self, logo_path: str) -> str:
243
+ r"""Load a local logo file and convert it to data URI.
244
+
245
+ Args:
246
+ logo_path (str): Path to the logo file
247
+
248
+ Returns:
249
+ str: Data URI of the logo file
250
+ """
251
+ try:
252
+ if not os.path.exists(logo_path):
253
+ logger.warning(f"Logo file not found: {logo_path}")
254
+ return self._get_default_logo()
255
+
256
+ # Get MIME type
257
+ mime_type, _ = mimetypes.guess_type(logo_path)
258
+ if not mime_type:
259
+ # Default MIME types for common formats
260
+ ext = os.path.splitext(logo_path)[1].lower()
261
+ mime_types_map = {
262
+ '.svg': 'image/svg+xml',
263
+ '.png': 'image/png',
264
+ '.jpg': 'image/jpeg',
265
+ '.jpeg': 'image/jpeg',
266
+ '.ico': 'image/x-icon',
267
+ '.gif': 'image/gif',
268
+ }
269
+ mime_type = mime_types_map.get(ext, 'image/png')
270
+
271
+ # Read file and encode to base64
272
+ with open(logo_path, 'rb') as f:
273
+ file_data = f.read()
274
+
275
+ base64_data = base64.b64encode(file_data).decode('utf-8')
276
+ return f"data:{mime_type};base64,{base64_data}"
277
+
278
+ except Exception as e:
279
+ logger.error(f"Error loading logo file {logo_path}: {e}")
280
+ return self._get_default_logo()
281
+
282
+ def _get_default_logo(self) -> str:
283
+ r"""Get the default logo as data URI.
284
+
285
+ Returns:
286
+ str: Default logo data URI
287
+ """
288
+ default_logo_data_uri = (
289
+ "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' "
290
+ "width='32' height='32' viewBox='0 0 32 32' fill='none'%3E%3Crect "
291
+ "width='32' height='32' rx='8' fill='%23333333'/%3E%3Ctext x='16' "
292
+ "y='22' font-family='system-ui, -apple-system, sans-serif' "
293
+ "font-size='12' font-weight='700' text-anchor='middle' "
294
+ "fill='white'%3EAI%3C/text%3E%3C/svg%3E"
295
+ )
296
+ return default_logo_data_uri
297
+
298
+ def deploy_html_content(
299
+ self,
300
+ html_content: Optional[str] = None,
301
+ html_file_path: Optional[str] = None,
302
+ file_name: str = "index.html",
303
+ port: int = 8000,
304
+ domain: Optional[str] = None,
305
+ subdirectory: Optional[str] = None,
306
+ ) -> Dict[str, Any]:
307
+ r"""Deploy HTML content to a local server or remote server.
308
+
309
+ Args:
310
+ html_content (Optional[str]): HTML content to deploy. Either this
311
+ or html_file_path must be provided.
312
+ html_file_path (Optional[str]): Path to HTML file to deploy. Either
313
+ this or html_content must be provided.
314
+ file_name (str): Name for the HTML file when using html_content.
315
+ (default: :obj:`index.html`)
316
+ port (int): Port to serve on. (default: :obj:`8000`)
317
+ domain (Optional[str]): Custom domain to access the content.
318
+ (e.g., :obj:`example.com`)
319
+ subdirectory (Optional[str]): Subdirectory path for multi-user
320
+ deployment. (e.g., :obj:`user123`)
321
+
322
+ Returns:
323
+ Dict[str, Any]: Deployment result with server URL and custom domain
324
+ info.
325
+ """
326
+ try:
327
+ # Validate inputs
328
+ if html_content is None and html_file_path is None:
329
+ return {
330
+ 'success': False,
331
+ 'error': (
332
+ 'Either html_content or html_file_path must be '
333
+ 'provided'
334
+ ),
335
+ }
336
+
337
+ if html_content is not None and html_file_path is not None:
338
+ return {
339
+ 'success': False,
340
+ 'error': (
341
+ 'Cannot provide both html_content and '
342
+ 'html_file_path'
343
+ ),
344
+ }
345
+
346
+ # Read content from file if file path is provided
347
+ if html_file_path:
348
+ if not os.path.exists(html_file_path):
349
+ return {
350
+ 'success': False,
351
+ 'error': f'HTML file not found: {html_file_path}',
352
+ }
353
+
354
+ try:
355
+ with open(html_file_path, 'r', encoding='utf-8') as f:
356
+ html_content = f.read()
357
+ # Use the original filename if deploying from file
358
+ file_name = os.path.basename(html_file_path)
359
+ except Exception as e:
360
+ return {
361
+ 'success': False,
362
+ 'error': f'Error reading HTML file: {e}',
363
+ }
364
+
365
+ # Check if remote deployment is configured
366
+ if self.remote_server_ip:
367
+ return self._deploy_to_remote_server(
368
+ html_content, # type: ignore[arg-type]
369
+ subdirectory,
370
+ domain,
371
+ )
372
+ else:
373
+ return self._deploy_to_local_server(
374
+ html_content, # type: ignore[arg-type]
375
+ file_name,
376
+ port,
377
+ domain,
378
+ subdirectory,
379
+ )
380
+
381
+ except Exception as e:
382
+ logger.error(f"Error deploying HTML content: {e}")
383
+ return {'success': False, 'error': str(e)}
384
+
385
+ def _deploy_to_remote_server(
386
+ self,
387
+ html_content: str,
388
+ subdirectory: Optional[str] = None,
389
+ domain: Optional[str] = None,
390
+ ) -> Dict[str, Any]:
391
+ r"""Deploy HTML content to remote server via API.
392
+
393
+ Args:
394
+ html_content (str): HTML content to deploy
395
+ subdirectory (Optional[str]): Subdirectory path for deployment
396
+ domain (Optional[str]): Custom domain
397
+
398
+ Returns:
399
+ Dict[str, Any]: Deployment result
400
+ """
401
+ try:
402
+ import requests
403
+
404
+ # Validate subdirectory
405
+ subdirectory = self._validate_subdirectory(subdirectory)
406
+
407
+ # Prepare deployment data
408
+ deploy_data = {
409
+ "html_content": html_content,
410
+ "subdirectory": subdirectory,
411
+ "domain": domain,
412
+ "timestamp": time.time(),
413
+ }
414
+
415
+ # Send to remote server API
416
+ api_url = f"http://{self.remote_server_ip}:{self.remote_server_port}/api/deploy"
417
+
418
+ response = requests.post(
419
+ api_url,
420
+ json=deploy_data,
421
+ timeout=self.timeout,
422
+ # Security: disable redirects to prevent SSRF
423
+ allow_redirects=False,
424
+ # Add headers for security
425
+ headers={'Content-Type': 'application/json'},
426
+ )
427
+
428
+ if response.status_code == 200:
429
+ response.json()
430
+
431
+ # Build URLs
432
+ base_url = (
433
+ f"http://{self.remote_server_ip}:{self.remote_server_port}"
434
+ )
435
+ deployed_url = (
436
+ f"{base_url}/{subdirectory}/" if subdirectory else base_url
437
+ )
438
+
439
+ return {
440
+ 'success': True,
441
+ 'remote_url': deployed_url,
442
+ 'server_ip': self.remote_server_ip,
443
+ 'subdirectory': subdirectory,
444
+ 'domain': domain,
445
+ 'message': f'Successfully deployed to remote server!\n • '
446
+ f'Access URL: {deployed_url}\n • Server: '
447
+ f'{self.remote_server_ip}:{self.remote_server_port}',
448
+ 'branding_tag_added': self.add_branding_tag,
449
+ }
450
+ else:
451
+ return {
452
+ 'success': False,
453
+ 'error': f'Remote deployment failed: HTTP '
454
+ f'{response.status_code}',
455
+ }
456
+
457
+ except ImportError:
458
+ return {
459
+ 'success': False,
460
+ 'error': 'Remote deployment requires requests library. '
461
+ 'Install with: pip install requests',
462
+ }
463
+ except Exception as e:
464
+ return {
465
+ 'success': False,
466
+ 'error': f'Remote deployment error: {e!s}',
467
+ }
468
+
469
+ def _deploy_to_local_server(
470
+ self,
471
+ html_content: str,
472
+ file_name: str,
473
+ port: int,
474
+ domain: Optional[str],
475
+ subdirectory: Optional[str],
476
+ ) -> Dict[str, Any]:
477
+ r"""Deploy HTML content to local server (original functionality).
478
+
479
+ Args:
480
+ html_content (str): HTML content to deploy
481
+ file_name (str): Name for the HTML file
482
+ port (int): Port to serve on (default: 8000)
483
+ domain (Optional[str]): Custom domain
484
+ subdirectory (Optional[str]): Subdirectory path
485
+
486
+ Returns:
487
+ Dict[str, Any]: Deployment result
488
+ """
489
+ temp_dir = None
490
+ try:
491
+ # Validate subdirectory
492
+ subdirectory = self._validate_subdirectory(subdirectory)
493
+
494
+ # Create temporary directory
495
+ temp_dir = tempfile.mkdtemp(prefix="web_deploy_")
496
+
497
+ # Handle subdirectory for multi-user deployment
498
+ if subdirectory:
499
+ deploy_dir = os.path.join(temp_dir, subdirectory)
500
+ os.makedirs(deploy_dir, exist_ok=True)
501
+ html_file_path = os.path.join(deploy_dir, file_name)
502
+ else:
503
+ html_file_path = os.path.join(temp_dir, file_name)
504
+
505
+ # Write enhanced HTML content to file
506
+ with open(html_file_path, 'w', encoding='utf-8') as f:
507
+ f.write(html_content)
508
+
509
+ # Start server
510
+ server_result = self._serve_static_files(temp_dir, port)
511
+
512
+ if server_result['success']:
513
+ # Build URLs with localhost fallback
514
+ local_url = server_result["server_url"]
515
+ if subdirectory:
516
+ local_url += f"/{subdirectory}"
517
+
518
+ # Custom domain URL (if provided)
519
+ custom_url = (
520
+ self._build_custom_url(domain, subdirectory)
521
+ if domain
522
+ else None
523
+ )
524
+
525
+ # Localhost fallback URL
526
+ localhost_url = f"http://localhost:{port}"
527
+ if subdirectory:
528
+ localhost_url += f"/{subdirectory}"
529
+
530
+ # Build message with all access options
531
+ message = 'HTML content deployed successfully!\n'
532
+ message += f' • Local access: {local_url}\n'
533
+ message += f' • Localhost fallback: {localhost_url}'
534
+
535
+ if custom_url:
536
+ message += f'\n • Custom domain: {custom_url}'
537
+
538
+ if self.add_branding_tag:
539
+ message += f'\n • Branding: "{self.tag_text}" tag added'
540
+
541
+ server_result.update(
542
+ {
543
+ 'html_file': html_file_path,
544
+ 'temp_directory': temp_dir,
545
+ 'local_url': local_url,
546
+ 'localhost_url': localhost_url,
547
+ 'custom_url': custom_url,
548
+ 'domain': domain,
549
+ 'subdirectory': subdirectory,
550
+ 'message': message,
551
+ 'branding_tag_added': self.add_branding_tag,
552
+ }
553
+ )
554
+
555
+ return server_result
556
+
557
+ except Exception as e:
558
+ # Clean up temp directory on error
559
+ if temp_dir and os.path.exists(temp_dir):
560
+ try:
561
+ shutil.rmtree(temp_dir)
562
+ except Exception:
563
+ pass
564
+ return {'success': False, 'error': str(e)}
565
+
566
+ def _serve_static_files(self, directory: str, port: int) -> Dict[str, Any]:
567
+ r"""Serve static files from a directory using a local HTTP server
568
+ (as a background process).
569
+
570
+ Args:
571
+ directory (str): Directory to serve files from
572
+ port (int): Port to serve on (default: 8000)
573
+
574
+ Returns:
575
+ Dict[str, Any]: Server information
576
+ """
577
+ import subprocess
578
+
579
+ try:
580
+ if not os.path.exists(directory):
581
+ return {
582
+ 'success': False,
583
+ 'error': f'Directory {directory} does not exist',
584
+ }
585
+
586
+ if not os.path.isdir(directory):
587
+ return {
588
+ 'success': False,
589
+ 'error': f'{directory} is not a directory',
590
+ }
591
+
592
+ # Validate port
593
+ port = self._validate_port(port)
594
+
595
+ # Check if port is already in use
596
+ if port in self.server_instances:
597
+ return {
598
+ 'success': False,
599
+ 'error': f'Port {port} is already in use by this toolkit',
600
+ }
601
+
602
+ # Check if port is available
603
+ if not self._is_port_available(port):
604
+ return {
605
+ 'success': False,
606
+ 'error': (
607
+ f'Port {port} is already in use by ' f'another process'
608
+ ),
609
+ }
610
+
611
+ # Start http.server as a background process with security
612
+ # improvements
613
+ process = subprocess.Popen(
614
+ [
615
+ "python3",
616
+ "-m",
617
+ "http.server",
618
+ str(port),
619
+ "--bind",
620
+ "127.0.0.1",
621
+ ],
622
+ cwd=directory,
623
+ stdout=subprocess.DEVNULL,
624
+ stderr=subprocess.DEVNULL,
625
+ shell=False, # Prevent shell injection
626
+ env={**os.environ, 'PYTHONDONTWRITEBYTECODE': '1'},
627
+ )
628
+
629
+ # Store both process and metadata for persistence
630
+ self.server_instances[port] = {
631
+ 'process': process,
632
+ 'pid': process.pid,
633
+ 'start_time': time.time(),
634
+ 'directory': directory,
635
+ }
636
+ self._save_server_registry()
637
+
638
+ # Wait for server to start with timeout
639
+ start_time = time.time()
640
+ while time.time() - start_time < 5:
641
+ if not self._is_port_available(port):
642
+ # Port is now in use, server started
643
+ break
644
+ time.sleep(0.1)
645
+ else:
646
+ # Server didn't start in time
647
+ process.terminate()
648
+ del self.server_instances[port]
649
+ return {
650
+ 'success': False,
651
+ 'error': f'Server failed to start on port {port}',
652
+ }
653
+
654
+ server_url = f"http://localhost:{port}"
655
+
656
+ return {
657
+ 'success': True,
658
+ 'server_url': server_url,
659
+ 'port': port,
660
+ 'directory': directory,
661
+ 'message': f'Static files served from {directory} at '
662
+ f'{server_url} (background process)',
663
+ }
664
+
665
+ except Exception as e:
666
+ logger.error(f"Error serving static files: {e}")
667
+ return {'success': False, 'error': str(e)}
668
+
669
+ def deploy_folder(
670
+ self,
671
+ folder_path: str,
672
+ port: int = 8000,
673
+ domain: Optional[str] = None,
674
+ subdirectory: Optional[str] = None,
675
+ ) -> Dict[str, Any]:
676
+ r"""Deploy a folder containing web files.
677
+
678
+ Args:
679
+ folder_path (str): Path to the folder to deploy.
680
+ port (int): Port to serve on. (default: :obj:`8000`)
681
+ domain (Optional[str]): Custom domain to access the content.
682
+ (e.g., :obj:`example.com`)
683
+ subdirectory (Optional[str]): Subdirectory path for multi-user
684
+ deployment. (e.g., :obj:`user123`)
685
+
686
+ Returns:
687
+ Dict[str, Any]: Deployment result with custom domain info.
688
+ """
689
+ try:
690
+ if not os.path.exists(folder_path):
691
+ return {
692
+ 'success': False,
693
+ 'error': f'Folder {folder_path} does not exist',
694
+ }
695
+
696
+ if not os.path.isdir(folder_path):
697
+ return {
698
+ 'success': False,
699
+ 'error': f'{folder_path} is not a directory',
700
+ }
701
+
702
+ # Validate subdirectory
703
+ subdirectory = self._validate_subdirectory(subdirectory)
704
+
705
+ temp_dir = None
706
+ if self.add_branding_tag:
707
+ # Create temporary directory and copy all files
708
+ temp_dir = tempfile.mkdtemp(prefix="web_deploy_enhanced_")
709
+
710
+ # Handle subdirectory structure
711
+ if subdirectory:
712
+ deploy_base = os.path.join(temp_dir, subdirectory)
713
+ os.makedirs(deploy_base, exist_ok=True)
714
+ shutil.copytree(
715
+ folder_path,
716
+ deploy_base,
717
+ dirs_exist_ok=True,
718
+ )
719
+ deploy_path = deploy_base
720
+ else:
721
+ shutil.copytree(
722
+ folder_path,
723
+ os.path.join(temp_dir, "site"),
724
+ dirs_exist_ok=True,
725
+ )
726
+ deploy_path = os.path.join(temp_dir, "site")
727
+
728
+ # Enhance HTML files with branding tag
729
+ html_files_enhanced = []
730
+ for root, _, files in os.walk(deploy_path):
731
+ for file in files:
732
+ if file.endswith('.html'):
733
+ html_file_path = os.path.join(root, file)
734
+ try:
735
+ with open(
736
+ html_file_path, 'r', encoding='utf-8'
737
+ ) as f:
738
+ original_content = f.read()
739
+
740
+ with open(
741
+ html_file_path, 'w', encoding='utf-8'
742
+ ) as f:
743
+ f.write(original_content)
744
+
745
+ html_files_enhanced.append(
746
+ os.path.relpath(
747
+ html_file_path, deploy_path
748
+ )
749
+ )
750
+ except Exception as e:
751
+ logger.warning(
752
+ f"Failed to enhance {html_file_path}: {e}"
753
+ )
754
+
755
+ # Serve the enhanced folder
756
+ server_result = self._serve_static_files(temp_dir, port)
757
+
758
+ if server_result['success']:
759
+ # Build URLs with localhost fallback
760
+ local_url = server_result["server_url"]
761
+ if subdirectory:
762
+ local_url += f"/{subdirectory}"
763
+
764
+ # Custom domain URL (if provided)
765
+ custom_url = (
766
+ self._build_custom_url(domain, subdirectory)
767
+ if domain
768
+ else None
769
+ )
770
+
771
+ # Localhost fallback URL
772
+ localhost_url = f"http://localhost:{port}"
773
+ if subdirectory:
774
+ localhost_url += f"/{subdirectory}"
775
+
776
+ # Build message with all access options
777
+ message = 'Folder deployed successfully!\n'
778
+ message += f' • Local access: {local_url}\n'
779
+ message += f' • Localhost fallback: {localhost_url}'
780
+
781
+ if custom_url:
782
+ message += f'\n • Custom domain: {custom_url}'
783
+
784
+ if self.add_branding_tag:
785
+ message += f'\n • Branding: "{self.tag_text}" tag '
786
+ message += (
787
+ f'added to {len(html_files_enhanced)} HTML files'
788
+ )
789
+
790
+ server_result.update(
791
+ {
792
+ 'original_folder': folder_path,
793
+ 'enhanced_folder': deploy_path,
794
+ 'html_files_enhanced': html_files_enhanced,
795
+ 'local_url': local_url,
796
+ 'localhost_url': localhost_url,
797
+ 'custom_url': custom_url,
798
+ 'domain': domain,
799
+ 'subdirectory': subdirectory,
800
+ 'branding_tag_added': True,
801
+ 'message': message,
802
+ }
803
+ )
804
+
805
+ return server_result
806
+ else:
807
+ # Check for index.html
808
+ index_html = os.path.join(folder_path, 'index.html')
809
+ if not os.path.exists(index_html):
810
+ logger.warning(f'No index.html found in {folder_path}')
811
+
812
+ # Handle subdirectory for original folder deployment
813
+ if subdirectory:
814
+ temp_dir = tempfile.mkdtemp(prefix="web_deploy_")
815
+ deploy_base = os.path.join(temp_dir, subdirectory)
816
+ shutil.copytree(
817
+ folder_path, deploy_base, dirs_exist_ok=True
818
+ )
819
+ deploy_path = temp_dir
820
+ else:
821
+ deploy_path = folder_path
822
+
823
+ # Serve the folder
824
+ server_result = self._serve_static_files(deploy_path, port)
825
+
826
+ if server_result['success']:
827
+ # Build URLs with localhost fallback
828
+ local_url = server_result["server_url"]
829
+ if subdirectory:
830
+ local_url += f"/{subdirectory}"
831
+
832
+ # Custom domain URL (if provided)
833
+ custom_url = (
834
+ self._build_custom_url(domain, subdirectory)
835
+ if domain
836
+ else None
837
+ )
838
+
839
+ # Localhost fallback URL
840
+ localhost_url = f"http://localhost:{port}"
841
+ if subdirectory:
842
+ localhost_url += f"/{subdirectory}"
843
+
844
+ # Build message with all access options
845
+ message = 'Folder deployed successfully!\n'
846
+ message += f' • Local access: {local_url}\n'
847
+ message += f' • Localhost fallback: {localhost_url}'
848
+
849
+ if custom_url:
850
+ message += f'\n • Custom domain: {custom_url}'
851
+
852
+ server_result.update(
853
+ {
854
+ 'local_url': local_url,
855
+ 'localhost_url': localhost_url,
856
+ 'custom_url': custom_url,
857
+ 'domain': domain,
858
+ 'subdirectory': subdirectory,
859
+ 'message': message,
860
+ 'branding_tag_added': False,
861
+ }
862
+ )
863
+
864
+ return server_result
865
+
866
+ except Exception as e:
867
+ # Clean up temp directory on error
868
+ if (
869
+ 'temp_dir' in locals()
870
+ and temp_dir
871
+ and os.path.exists(temp_dir)
872
+ ):
873
+ try:
874
+ shutil.rmtree(temp_dir)
875
+ except Exception:
876
+ pass
877
+ logger.error(f"Error deploying folder: {e}")
878
+ return {'success': False, 'error': str(e)}
879
+
880
+ def stop_server(self, port: int) -> Dict[str, Any]:
881
+ r"""Stop a running server on the specified port.
882
+
883
+ Args:
884
+ port (int): Port of the server to stop.
885
+
886
+ Returns:
887
+ Dict[str, Any]: Result of stopping the server.
888
+ """
889
+ try:
890
+ # Validate port
891
+ port = self._validate_port(port)
892
+ # First check persistent registry for servers
893
+ self._load_server_registry()
894
+
895
+ if port not in self.server_instances:
896
+ # Check if there's a process running on this port by PID
897
+ if os.path.exists(self.server_registry_file):
898
+ with open(self.server_registry_file, 'r') as f:
899
+ data = json.load(f)
900
+ port_str = str(port)
901
+ if port_str in data:
902
+ pid = data[port_str].get('pid')
903
+ if pid and self._is_process_running(pid):
904
+ try:
905
+ os.kill(pid, 15) # SIGTERM
906
+ # Remove from registry
907
+ del data[port_str]
908
+ with open(
909
+ self.server_registry_file, 'w'
910
+ ) as f:
911
+ json.dump(data, f, indent=2)
912
+ return {
913
+ 'success': True,
914
+ 'port': port,
915
+ 'message': (
916
+ f'Server on port {port} stopped '
917
+ f'successfully (from registry)'
918
+ ),
919
+ }
920
+ except Exception as e:
921
+ logger.error(
922
+ f"Error stopping server by PID: {e}"
923
+ )
924
+
925
+ return {
926
+ 'success': False,
927
+ 'error': f'No server running on port {port}',
928
+ }
929
+
930
+ server_info = self.server_instances[port]
931
+ if isinstance(server_info, dict):
932
+ process = server_info.get('process')
933
+ pid = server_info.get('pid')
934
+
935
+ # Stop the main server process
936
+ if process:
937
+ process.terminate()
938
+ process.wait(
939
+ timeout=5
940
+ ) # Wait for process to terminate gracefully
941
+ elif pid and self._is_process_running(pid):
942
+ os.kill(pid, 15) # SIGTERM
943
+
944
+ else:
945
+ # Handle old-style direct process objects
946
+ server_info.terminate()
947
+ server_info.wait(timeout=5)
948
+
949
+ del self.server_instances[port]
950
+ self._save_server_registry()
951
+
952
+ return {
953
+ 'success': True,
954
+ 'port': port,
955
+ 'message': f'Server on port {port} stopped successfully',
956
+ }
957
+
958
+ except subprocess.TimeoutExpired:
959
+ if isinstance(server_info, dict):
960
+ process = server_info.get('process')
961
+
962
+ if process:
963
+ process.kill()
964
+ process.wait(timeout=5)
965
+ else:
966
+ server_info.kill()
967
+ server_info.wait(timeout=5)
968
+ del self.server_instances[port]
969
+ self._save_server_registry()
970
+ return {
971
+ 'success': True,
972
+ 'port': port,
973
+ 'message': f'Server on port {port} stopped after timeout',
974
+ }
975
+ except Exception as e:
976
+ logger.error(f"Error stopping server: {e}")
977
+ return {'success': False, 'error': str(e)}
978
+
979
+ def list_running_servers(self) -> Dict[str, Any]:
980
+ r"""List all currently running servers.
981
+
982
+ Returns:
983
+ Dict[str, Any]: Information about running servers
984
+ """
985
+ try:
986
+ self._load_server_registry()
987
+
988
+ running_servers = []
989
+ current_time = time.time()
990
+
991
+ for port, server_info in self.server_instances.items():
992
+ if isinstance(server_info, dict):
993
+ start_time = server_info.get('start_time', 0)
994
+ running_time = current_time - start_time
995
+
996
+ running_servers.append(
997
+ {
998
+ 'port': port,
999
+ 'pid': server_info.get('pid'),
1000
+ 'directory': server_info.get('directory'),
1001
+ 'start_time': start_time,
1002
+ 'running_time': running_time,
1003
+ 'url': f'http://localhost:{port}',
1004
+ }
1005
+ )
1006
+
1007
+ return {
1008
+ 'success': True,
1009
+ 'servers': running_servers,
1010
+ 'total_servers': len(running_servers),
1011
+ }
1012
+
1013
+ except Exception as e:
1014
+ logger.error(f"Error listing servers: {e}")
1015
+ return {'success': False, 'error': str(e)}
1016
+
1017
+ def get_tools(self) -> List[FunctionTool]:
1018
+ r"""Get all available tools from the WebDeployToolkit."""
1019
+ return [
1020
+ FunctionTool(self.deploy_html_content),
1021
+ FunctionTool(self.deploy_folder),
1022
+ FunctionTool(self.stop_server),
1023
+ FunctionTool(self.list_running_servers),
1024
+ ]