openenv-testing/echo_env
0
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the BSD-style license found in the5# LICENSE file in the root directory of this source tree.6 7"""8Web interface for OpenEnv environments.9 10This module provides a web-based interface for interacting with OpenEnv environments,11including a two-pane layout for HumanAgent interaction and state observation.12"""13 14from __future__ import annotations15 16import json17import time18from dataclasses import asdict, dataclass19from typing import Any, Dict, List, Optional, Type20from datetime import datetime21 22from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Request23from fastapi.responses import HTMLResponse, FileResponse24from fastapi.staticfiles import StaticFiles25from pydantic import BaseModel26 27from .interfaces import Environment28from .types import Action, Observation, State, EnvironmentMetadata29 30 31def load_environment_metadata(env: Environment, env_name: Optional[str] = None) -> EnvironmentMetadata:32 """33 Load environment metadata including README content.34 35 Args:36 env: The environment instance37 env_name: Optional environment name for README file lookup38 39 Returns:40 EnvironmentMetadata with loaded information41 """42 # Try to get metadata from environment if it has a method for it43 if hasattr(env, 'get_metadata'):44 return env.get_metadata()45 46 # Default metadata47 metadata = EnvironmentMetadata(48 name=env_name or env.__class__.__name__,49 description=f"{env.__class__.__name__} environment",50 version="1.0.0"51 )52 53 # Try to load README from file system54 readme_content = _load_readme_from_filesystem(env_name)55 if readme_content:56 metadata.readme_content = readme_content57 58 return metadata59 60 61def _load_readme_from_filesystem(env_name: Optional[str]) -> Optional[str]:62 """63 Load README content from the filesystem.64 65 Tries multiple locations:66 1. Container filesystem: /app/README.md67 2. Local development: src/envs/{env_name}/README.md68 3. Environment variable: ENV_README_PATH69 """70 import os71 from pathlib import Path72 73 # Try container filesystem first74 container_readme = Path("/app/README.md")75 if container_readme.exists():76 try:77 return container_readme.read_text(encoding='utf-8')78 except Exception:79 pass80 81 # Try environment variable path82 custom_path = os.environ.get("ENV_README_PATH")83 if custom_path and Path(custom_path).exists():84 try:85 return Path(custom_path).read_text(encoding='utf-8')86 except Exception:87 pass88 89 # Try local development path90 if env_name:91 local_readme = Path(f"src/envs/{env_name}/README.md")92 if local_readme.exists():93 try:94 return local_readme.read_text(encoding='utf-8')95 except Exception:96 pass97 98 return None99 100 101@dataclass102class ActionLog:103 """Log entry for an action taken."""104 timestamp: str105 action: Dict[str, Any]106 observation: Dict[str, Any]107 reward: Optional[float]108 done: bool109 step_count: int110 111 112@dataclass113class EpisodeState:114 """Current episode state for the web interface."""115 episode_id: Optional[str]116 step_count: int117 current_observation: Optional[Dict[str, Any]]118 action_logs: List[ActionLog]119 is_reset: bool = True120 121 122class WebInterfaceManager:123 """Manages the web interface for an environment."""124 125 def __init__(126 self,127 env: Environment,128 action_cls: Type[Action],129 observation_cls: Type[Observation],130 metadata: Optional[EnvironmentMetadata] = None,131 ):132 self.env = env133 self.action_cls = action_cls134 self.observation_cls = observation_cls135 self.metadata = metadata or EnvironmentMetadata(136 name=env.__class__.__name__,137 description=f"{env.__class__.__name__} environment"138 )139 self.episode_state = EpisodeState(140 episode_id=None,141 step_count=0,142 current_observation=None,143 action_logs=[]144 )145 self.connected_clients: List[WebSocket] = []146 147 async def connect_websocket(self, websocket: WebSocket):148 """Connect a new WebSocket client."""149 await websocket.accept()150 self.connected_clients.append(websocket)151 152 # Send current state to the new client153 await self._send_state_update()154 155 async def disconnect_websocket(self, websocket: WebSocket):156 """Disconnect a WebSocket client."""157 if websocket in self.connected_clients:158 self.connected_clients.remove(websocket)159 160 async def _send_state_update(self):161 """Send current state to all connected clients."""162 if not self.connected_clients:163 return164 165 state_data = {166 "type": "state_update",167 "episode_state": asdict(self.episode_state)168 }169 170 # Send to all connected clients171 disconnected_clients = []172 for client in self.connected_clients:173 try:174 await client.send_text(json.dumps(state_data))175 except:176 disconnected_clients.append(client)177 178 # Remove disconnected clients179 for client in disconnected_clients:180 self.connected_clients.remove(client)181 182 async def reset_environment(self) -> Dict[str, Any]:183 """Reset the environment and update state."""184 observation = self.env.reset()185 state = self.env.state186 187 # Update episode state188 self.episode_state.episode_id = state.episode_id189 self.episode_state.step_count = 0190 self.episode_state.current_observation = asdict(observation)191 self.episode_state.action_logs = []192 self.episode_state.is_reset = True193 194 # Send state update195 await self._send_state_update()196 197 return {198 "observation": asdict(observation),199 "reward": observation.reward,200 "done": observation.done,201 }202 203 async def step_environment(self, action_data: Dict[str, Any]) -> Dict[str, Any]:204 """Execute a step in the environment and update state."""205 # Deserialize action206 action = self._deserialize_action(action_data)207 208 # Execute step209 observation = self.env.step(action)210 state = self.env.state211 212 # Create action log213 action_log = ActionLog(214 timestamp=datetime.now().isoformat(),215 action=asdict(action),216 observation=asdict(observation),217 reward=observation.reward,218 done=observation.done,219 step_count=state.step_count220 )221 222 # Update episode state223 self.episode_state.episode_id = state.episode_id224 self.episode_state.step_count = state.step_count225 self.episode_state.current_observation = asdict(observation)226 self.episode_state.action_logs.append(action_log)227 self.episode_state.is_reset = False228 229 # Send state update230 await self._send_state_update()231 232 return {233 "observation": asdict(observation),234 "reward": observation.reward,235 "done": observation.done,236 }237 238 def get_state(self) -> Dict[str, Any]:239 """Get current environment state."""240 state = self.env.state241 return asdict(state)242 243 def _deserialize_action(self, action_data: Dict[str, Any]) -> Action:244 """Convert JSON dict to Action instance."""245 metadata = action_data.pop("metadata", {})246 247 # Handle tensor fields that come from JSON as lists248 processed_data = {}249 for key, value in action_data.items():250 if key == "tokens" and isinstance(value, (list, str)):251 # Convert list or string to tensor252 if isinstance(value, str):253 # If it's a string, try to parse it as a list of numbers254 try:255 import json256 value = json.loads(value)257 except:258 # If parsing fails, treat as empty list259 value = []260 if isinstance(value, list):261 import torch262 processed_data[key] = torch.tensor(value, dtype=torch.long)263 else:264 processed_data[key] = value265 elif key == "action_id" and isinstance(value, str):266 # Convert action_id from string to int267 try:268 processed_data[key] = int(value)269 except ValueError:270 # If conversion fails, keep original value271 processed_data[key] = value272 else:273 processed_data[key] = value274 275 action = self.action_cls(**processed_data)276 action.metadata = metadata277 return action278 279 280def create_web_interface_app(281 env: Environment,282 action_cls: Type[Action],283 observation_cls: Type[Observation],284 env_name: Optional[str] = None,285) -> FastAPI:286 """287 Create a FastAPI application with web interface for the given environment.288 289 Args:290 env: The Environment instance to serve291 action_cls: The Action subclass this environment expects292 observation_cls: The Observation subclass this environment returns293 env_name: Optional environment name for README loading294 295 Returns:296 FastAPI application instance with web interface297 """298 from .http_server import create_fastapi_app299 300 # Create the base environment app301 app = create_fastapi_app(env, action_cls, observation_cls)302 303 # Load environment metadata304 metadata = load_environment_metadata(env, env_name)305 306 # Create web interface manager307 web_manager = WebInterfaceManager(env, action_cls, observation_cls, metadata)308 309 # Add web interface routes310 @app.get("/web", response_class=HTMLResponse)311 async def web_interface():312 """Serve the web interface."""313 return get_web_interface_html(action_cls, web_manager.metadata)314 315 @app.get("/web/metadata")316 async def web_metadata():317 """Get environment metadata."""318 return asdict(web_manager.metadata)319 320 @app.websocket("/ws")321 async def websocket_endpoint(websocket: WebSocket):322 """WebSocket endpoint for real-time updates."""323 await web_manager.connect_websocket(websocket)324 try:325 while True:326 # Keep connection alive327 await websocket.receive_text()328 except WebSocketDisconnect:329 await web_manager.disconnect_websocket(websocket)330 331 @app.post("/web/reset")332 async def web_reset():333 """Reset endpoint for web interface."""334 return await web_manager.reset_environment()335 336 @app.post("/web/step")337 async def web_step(request: Dict[str, Any]):338 """Step endpoint for web interface."""339 # Check if this is a message-based request (chat environment)340 if "message" in request:341 message = request["message"]342 # Convert message to action using the environment's message_to_action method343 action = web_manager.env.message_to_action(message)344 action_data = {"tokens": action.tokens.tolist()}345 else:346 action_data = request.get("action", {})347 348 return await web_manager.step_environment(action_data)349 350 @app.get("/web/state")351 async def web_state():352 """State endpoint for web interface."""353 return web_manager.get_state()354 355 return app356 357 358def get_web_interface_html(action_cls: Type[Action], metadata: Optional[EnvironmentMetadata] = None) -> str:359 """Generate the HTML for the web interface."""360 361 # Check if this is a chat environment by looking for tokens field362 is_chat_env = False363 if hasattr(action_cls, '__dataclass_fields__'):364 for field_name, field_info in action_cls.__dataclass_fields__.items():365 if field_name == 'tokens' and hasattr(field_info.type, '__name__') and 'Tensor' in field_info.type.__name__:366 is_chat_env = True367 break368 369 # Get action fields for dynamic form generation with enhanced metadata370 action_fields = _extract_action_fields(action_cls)371 372 return f"""373<!DOCTYPE html>374<html lang="en">375<head>376 <meta charset="UTF-8">377 <meta name="viewport" content="width=device-width, initial-scale=1.0">378 <title>OpenEnv Web Interface</title>379 <style>380 * {{381 margin: 0;382 padding: 0;383 box-sizing: border-box;384 }}385 386 body {{387 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;388 background-color: #f5f5f5;389 height: 100vh;390 overflow: hidden;391 }}392 393 .container {{394 display: flex;395 height: 100vh;396 }}397 398 .left-pane {{399 width: 50%;400 background: white;401 border-right: 1px solid #e0e0e0;402 display: flex;403 flex-direction: column;404 }}405 406 .right-pane {{407 width: 50%;408 background: #fafafa;409 display: flex;410 flex-direction: column;411 }}412 413 .pane-header {{414 padding: 20px;415 border-bottom: 1px solid #e0e0e0;416 background: #f8f9fa;417 font-weight: 600;418 font-size: 16px;419 }}420 421 .pane-content {{422 flex: 1;423 padding: 20px;424 overflow-y: auto;425 }}426 427 .action-form {{428 background: white;429 border: 1px solid #e0e0e0;430 border-radius: 8px;431 padding: 20px;432 margin-bottom: 20px;433 }}434 435 .form-group {{436 margin-bottom: 15px;437 }}438 439 .form-group label {{440 display: block;441 margin-bottom: 5px;442 font-weight: 500;443 color: #333;444 }}445 446 .form-group input, .form-group textarea {{447 width: 100%;448 padding: 8px 12px;449 border: 1px solid #ddd;450 border-radius: 4px;451 font-size: 14px;452 }}453 454 .form-group input:focus, .form-group textarea:focus {{455 outline: none;456 border-color: #007bff;457 box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);458 }}459 460 .btn {{461 background: #007bff;462 color: white;463 border: none;464 padding: 10px 20px;465 border-radius: 4px;466 cursor: pointer;467 font-size: 14px;468 margin-right: 10px;469 margin-bottom: 10px;470 }}471 472 .btn:hover {{473 background: #0056b3;474 }}475 476 .btn:disabled {{477 background: #6c757d;478 cursor: not-allowed;479 }}480 481 .btn-secondary {{482 background: #6c757d;483 }}484 485 .btn-secondary:hover {{486 background: #545b62;487 }}488 489 .state-display {{490 background: white;491 border: 1px solid #e0e0e0;492 border-radius: 8px;493 padding: 15px;494 margin-bottom: 20px;495 }}496 497 .state-item {{498 margin-bottom: 8px;499 }}500 501 .state-label {{502 font-weight: 500;503 color: #666;504 }}505 506 .state-value {{507 color: #333;508 font-family: monospace;509 }}510 511 .logs-container {{512 background: white;513 border: 1px solid #e0e0e0;514 border-radius: 8px;515 padding: 15px;516 max-height: 400px;517 overflow-y: auto;518 }}519 520 .log-entry {{521 border-bottom: 1px solid #f0f0f0;522 padding: 10px 0;523 }}524 525 .log-entry:last-child {{526 border-bottom: none;527 }}528 529 .log-timestamp {{530 font-size: 12px;531 color: #666;532 margin-bottom: 5px;533 }}534 535 .log-action {{536 background: #e3f2fd;537 padding: 8px;538 border-radius: 4px;539 margin-bottom: 5px;540 font-family: monospace;541 font-size: 12px;542 }}543 544 .log-observation {{545 background: #f3e5f5;546 padding: 8px;547 border-radius: 4px;548 font-family: monospace;549 font-size: 12px;550 }}551 552 .log-reward {{553 font-weight: 600;554 color: #28a745;555 }}556 557 .log-done {{558 font-weight: 600;559 color: #dc3545;560 }}561 562 .status-indicator {{563 display: inline-block;564 width: 8px;565 height: 8px;566 border-radius: 50%;567 margin-right: 8px;568 }}569 570 .status-connected {{571 background: #28a745;572 }}573 574 .status-disconnected {{575 background: #dc3545;576 }}577 578 .json-display {{579 background: #f8f9fa;580 border: 1px solid #e9ecef;581 border-radius: 4px;582 padding: 10px;583 font-family: monospace;584 font-size: 12px;585 white-space: pre-wrap;586 max-height: 200px;587 overflow-y: auto;588 }}589 590 /* Chat Interface Styles */591 .chat-interface {{592 background: white;593 border: 1px solid #e0e0e0;594 border-radius: 8px;595 padding: 20px;596 margin-bottom: 20px;597 }}598 599 .chat-messages {{600 background: #f8f9fa;601 border: 1px solid #e0e0e0;602 border-radius: 8px;603 padding: 15px;604 margin-bottom: 15px;605 max-height: 400px;606 overflow-y: auto;607 }}608 609 .chat-message {{610 margin-bottom: 15px;611 padding: 10px;612 border-radius: 8px;613 }}614 615 .chat-message:last-child {{616 margin-bottom: 0;617 }}618 619 .chat-message.user {{620 background: #e3f2fd;621 margin-left: 20px;622 }}623 624 .chat-message.assistant {{625 background: #f3e5f5;626 margin-right: 20px;627 }}628 629 .chat-message.system {{630 background: #e8f5e8;631 font-style: italic;632 }}633 634 .message-role {{635 font-weight: 600;636 font-size: 12px;637 color: #666;638 margin-bottom: 5px;639 }}640 641 .message-content {{642 font-size: 14px;643 line-height: 1.4;644 }}645 646 .chat-input-container {{647 border-top: 1px solid #e0e0e0;648 padding-top: 15px;649 }}650 651 .role-selector {{652 margin-bottom: 10px;653 }}654 655 .role-selector label {{656 font-weight: 500;657 margin-right: 10px;658 }}659 660 .role-selector select {{661 padding: 5px 10px;662 border: 1px solid #ddd;663 border-radius: 4px;664 }}665 666 .message-input {{667 display: flex;668 gap: 10px;669 align-items: flex-end;670 }}671 672 .message-input textarea {{673 flex: 1;674 padding: 10px;675 border: 1px solid #ddd;676 border-radius: 4px;677 resize: vertical;678 font-family: inherit;679 }}680 681 .message-input textarea:focus {{682 outline: none;683 border-color: #007bff;684 box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);685 }}686 687 /* Instructions Section Styles */688 .instructions-section {{689 background: white;690 border: 1px solid #e0e0e0;691 border-radius: 8px;692 padding: 20px;693 margin-bottom: 20px;694 }}695 696 .instructions-header {{697 display: flex;698 justify-content: space-between;699 align-items: center;700 margin-bottom: 15px;701 }}702 703 .instructions-title {{704 font-size: 18px;705 font-weight: 600;706 color: #333;707 margin: 0;708 }}709 710 .instructions-toggle {{711 background: #f8f9fa;712 border: 1px solid #dee2e6;713 border-radius: 4px;714 padding: 5px 10px;715 cursor: pointer;716 font-size: 12px;717 color: #6c757d;718 }}719 720 .instructions-toggle:hover {{721 background: #e9ecef;722 }}723 724 .instructions-content {{725 display: none;726 max-height: 400px;727 overflow-y: auto;728 border-top: 1px solid #e0e0e0;729 padding-top: 15px;730 }}731 732 .instructions-content.expanded {{733 display: block;734 }}735 736 .instructions-content h1,737 .instructions-content h2,738 .instructions-content h3 {{739 color: #333;740 margin-top: 20px;741 margin-bottom: 10px;742 }}743 744 .instructions-content h1 {{745 font-size: 24px;746 border-bottom: 2px solid #007bff;747 padding-bottom: 10px;748 }}749 750 .instructions-content h2 {{751 font-size: 20px;752 }}753 754 .instructions-content h3 {{755 font-size: 16px;756 }}757 758 .instructions-content p {{759 margin-bottom: 10px;760 line-height: 1.6;761 }}762 763 .instructions-content code {{764 background: #f8f9fa;765 padding: 2px 4px;766 border-radius: 3px;767 font-family: monospace;768 font-size: 14px;769 }}770 771 .instructions-content pre {{772 background: #f8f9fa;773 border: 1px solid #e9ecef;774 border-radius: 4px;775 padding: 15px;776 overflow-x: auto;777 margin: 10px 0;778 }}779 780 .instructions-content pre code {{781 background: none;782 padding: 0;783 }}784 785 .instructions-content ul,786 .instructions-content ol {{787 margin: 10px 0;788 padding-left: 20px;789 }}790 791 .instructions-content li {{792 margin-bottom: 5px;793 }}794 795 .instructions-content table {{796 border-collapse: collapse;797 width: 100%;798 margin: 15px 0;799 }}800 801 .instructions-content th,802 .instructions-content td {{803 border: 1px solid #dee2e6;804 padding: 8px 12px;805 text-align: left;806 }}807 808 .instructions-content th {{809 background: #f8f9fa;810 font-weight: 600;811 }}812 813 /* Enhanced Form Styles */814 .help-text {{815 display: block;816 margin-top: 5px;817 font-size: 12px;818 color: #6c757d;819 font-style: italic;820 }}821 822 .form-group label {{823 font-weight: 500;824 color: #333;825 margin-bottom: 5px;826 }}827 828 .form-group select {{829 width: 100%;830 padding: 8px 12px;831 border: 1px solid #ddd;832 border-radius: 4px;833 font-size: 14px;834 background-color: white;835 }}836 837 .form-group select:focus {{838 outline: none;839 border-color: #007bff;840 box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);841 }}842 843 .form-group textarea {{844 width: 100%;845 padding: 8px 12px;846 border: 1px solid #ddd;847 border-radius: 4px;848 font-size: 14px;849 font-family: inherit;850 resize: vertical;851 }}852 853 .form-group textarea:focus {{854 outline: none;855 border-color: #007bff;856 box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);857 }}858 859 .form-group input[type="number"] {{860 width: 100%;861 padding: 8px 12px;862 border: 1px solid #ddd;863 border-radius: 4px;864 font-size: 14px;865 }}866 867 .form-group input[type="number"]:focus {{868 outline: none;869 border-color: #007bff;870 box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);871 }}872 873 .form-group input[type="text"]:focus {{874 outline: none;875 border-color: #007bff;876 box-shadow: 0 0 0 2px rgba(0, 123, 255, 0.25);877 }}878 879 .required-indicator {{880 color: #dc3545;881 font-weight: bold;882 }}883 884 .form-group .field-description {{885 font-size: 11px;886 color: #666;887 margin-top: 2px;888 font-style: italic;889 }}890 </style>891</head>892<body>893 <div class="container">894 <!-- Left Pane: HumanAgent Interface -->895 <div class="left-pane">896 <div class="pane-header">897 <span class="status-indicator status-disconnected" id="connection-status"></span>898 HumanAgent Interface899 </div>900 <div class="pane-content">901 <!-- Instructions Section -->902 {_generate_instructions_section(metadata)}903 904 <!-- Action Form or Chat Interface -->905 {_generate_action_interface(action_fields, is_chat_env)}906 907 <!-- Control Buttons -->908 <div style="margin-bottom: 20px;">909 <button class="btn btn-secondary" id="reset-btn">Reset Environment</button>910 <button class="btn btn-secondary" id="state-btn">Get State</button>911 </div>912 913 <!-- Current State Display -->914 <div class="state-display">915 <h3>Current State</h3>916 <div id="current-state">917 <div class="state-item">918 <span class="state-label">Status:</span>919 <span class="state-value" id="env-status">Not initialized</span>920 </div>921 <div class="state-item">922 <span class="state-label">Episode ID:</span>923 <span class="state-value" id="episode-id">-</span>924 </div>925 <div class="state-item">926 <span class="state-label">Step Count:</span>927 <span class="state-value" id="step-count">0</span>928 </div>929 </div>930 </div>931 </div>932 </div>933 934 <!-- Right Pane: State Observer -->935 <div class="right-pane">936 <div class="pane-header">937 State Observer938 </div>939 <div class="pane-content">940 <!-- Current Observation -->941 <div class="state-display">942 <h3>Current Observation</h3>943 <div id="current-observation" class="json-display">944 No observation yet945 </div>946 </div>947 948 <!-- Action Logs -->949 <div class="logs-container">950 <h3>Action History</h3>951 <div id="action-logs">952 No actions taken yet953 </div>954 </div>955 </div>956 </div>957 </div>958 959 <script>960 class OpenEnvWebInterface {{961 constructor() {{962 this.ws = null;963 this.isConnected = false;964 this.init();965 }}966 967 init() {{968 this.connectWebSocket();969 this.setupEventListeners();970 }}971 972 connectWebSocket() {{973 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';974 const wsUrl = `${{protocol}}//${{window.location.host}}/ws`;975 976 this.ws = new WebSocket(wsUrl);977 978 this.ws.onopen = () => {{979 this.isConnected = true;980 this.updateConnectionStatus(true);981 console.log('WebSocket connected');982 }};983 984 this.ws.onmessage = (event) => {{985 const data = JSON.parse(event.data);986 if (data.type === 'state_update') {{987 this.updateUI(data.episode_state);988 }}989 }};990 991 this.ws.onclose = () => {{992 this.isConnected = false;993 this.updateConnectionStatus(false);994 console.log('WebSocket disconnected');995 // Attempt to reconnect after 3 seconds996 setTimeout(() => this.connectWebSocket(), 3000);997 }};998 999 this.ws.onerror = (error) => {{1000 console.error('WebSocket error:', error);1001 }};1002 }}1003 1004 setupEventListeners() {{1005 // Instructions toggle1006 const instructionsToggle = document.getElementById('instructions-toggle');1007 const instructionsContent = document.getElementById('instructions-content');1008 if (instructionsToggle && instructionsContent) {{1009 instructionsToggle.addEventListener('click', () => {{1010 instructionsContent.classList.toggle('expanded');1011 instructionsToggle.textContent = instructionsContent.classList.contains('expanded') 1012 ? 'Hide Instructions' : 'Show Instructions';1013 }});1014 }}1015 1016 // Check if this is a chat environment1017 const isChatEnv = document.getElementById('chat-messages') !== null;1018 1019 if (isChatEnv) {{1020 // Chat environment event listeners1021 document.getElementById('send-message-btn').addEventListener('click', () => {{1022 this.sendMessage();1023 }});1024 1025 // Send message on Enter (but allow Shift+Enter for new lines)1026 document.getElementById('message-input').addEventListener('keydown', (e) => {{1027 if (e.key === 'Enter' && !e.shiftKey) {{1028 e.preventDefault();1029 this.sendMessage();1030 }}1031 }});1032 }} else {{1033 // Traditional action form submission1034 const actionForm = document.getElementById('action-form');1035 if (actionForm) {{1036 actionForm.addEventListener('submit', (e) => {{1037 e.preventDefault();1038 this.submitAction();1039 }});1040 }}1041 }}1042 1043 // Reset button1044 document.getElementById('reset-btn').addEventListener('click', () => {{1045 this.resetEnvironment();1046 }});1047 1048 // State button1049 document.getElementById('state-btn').addEventListener('click', () => {{1050 this.getState();1051 }});1052 }}1053 1054 async sendMessage() {{1055 const messageInput = document.getElementById('message-input');1056 const roleSelect = document.getElementById('message-role');1057 const message = messageInput.value.trim();1058 const role = roleSelect.value;1059 1060 if (!message) {{1061 return;1062 }}1063 1064 // Add message to chat display immediately1065 this.addMessageToChat(role, message);1066 1067 // Clear input1068 messageInput.value = '';1069 1070 try {{1071 // Send message to server to convert to action and step1072 const response = await fetch('/web/step', {{1073 method: 'POST',1074 headers: {{ 'Content-Type': 'application/json' }},1075 body: JSON.stringify({{ 1076 message: {{1077 role: role,1078 content: message1079 }}1080 }})1081 }});1082 1083 if (!response.ok) {{1084 throw new Error(`HTTP error! status: ${{response.status}}`);1085 }}1086 1087 const result = await response.json();1088 console.log('Message sent:', result);1089 }} catch (error) {{1090 console.error('Error sending message:', error);1091 alert('Error sending message: ' + error.message);1092 }}1093 }}1094 1095 addMessageToChat(role, content) {{1096 const chatMessages = document.getElementById('chat-messages');1097 const messageDiv = document.createElement('div');1098 messageDiv.className = `chat-message ${{role}}`;1099 1100 messageDiv.innerHTML = `1101 <div class="message-role">${{role.charAt(0).toUpperCase() + role.slice(1)}}</div>1102 <div class="message-content">${{content}}</div>1103 `;1104 1105 chatMessages.appendChild(messageDiv);1106 chatMessages.scrollTop = chatMessages.scrollHeight;1107 }}1108 1109 async submitAction() {{1110 const formData = new FormData(document.getElementById('action-form'));1111 const action = {{}};1112 1113 // Collect form data1114 for (const [key, value] of formData.entries()) {{1115 if (value !== '') {{1116 // Handle tensor fields (tokens) - convert comma-separated string to array1117 if (key === 'tokens') {{1118 try {{1119 action[key] = value.split(',').map(x => parseInt(x.trim())).filter(x => !isNaN(x));1120 }} catch (e) {{1121 console.error('Error parsing tokens:', e);1122 action[key] = [];1123 }}1124 }} else {{1125 action[key] = value;1126 }}1127 }}1128 }}1129 1130 try {{1131 const response = await fetch('/web/step', {{1132 method: 'POST',1133 headers: {{ 'Content-Type': 'application/json' }},1134 body: JSON.stringify({{ action }})1135 }});1136 1137 if (!response.ok) {{1138 throw new Error(`HTTP error! status: ${{response.status}}`);1139 }}1140 1141 const result = await response.json();1142 console.log('Step result:', result);1143 }} catch (error) {{1144 console.error('Error submitting action:', error);1145 alert('Error submitting action: ' + error.message);1146 }}1147 }}1148 1149 async resetEnvironment() {{1150 try {{1151 const response = await fetch('/web/reset', {{1152 method: 'POST',1153 headers: {{ 'Content-Type': 'application/json' }}1154 }});1155 1156 if (!response.ok) {{1157 throw new Error(`HTTP error! status: ${{response.status}}`);1158 }}1159 1160 const result = await response.json();1161 console.log('Reset result:', result);1162 }} catch (error) {{1163 console.error('Error resetting environment:', error);1164 alert('Error resetting environment: ' + error.message);1165 }}1166 }}1167 1168 async getState() {{1169 try {{1170 const response = await fetch('/web/state');1171 const state = await response.json();1172 console.log('Current state:', state);1173 alert('Current state: ' + JSON.stringify(state, null, 2));1174 }} catch (error) {{1175 console.error('Error getting state:', error);1176 alert('Error getting state: ' + error.message);1177 }}1178 }}1179 1180 updateConnectionStatus(connected) {{1181 const indicator = document.getElementById('connection-status');1182 if (connected) {{1183 indicator.className = 'status-indicator status-connected';1184 }} else {{1185 indicator.className = 'status-indicator status-disconnected';1186 }}1187 }}1188 1189 updateUI(episodeState) {{1190 // Check if this is a chat environment1191 const isChatEnv = document.getElementById('chat-messages') !== null;1192 1193 // Update current state1194 document.getElementById('env-status').textContent = 1195 episodeState.is_reset ? 'Reset' : 'Running';1196 document.getElementById('episode-id').textContent = 1197 episodeState.episode_id || '-';1198 document.getElementById('step-count').textContent = 1199 episodeState.step_count.toString();1200 